wx-downloder 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ dist/
6
+ *.egg-info/
7
+ *.part
8
+ auth.json
9
+ .env
10
+ .DS_Store
11
+ .playwright-cli/
12
+ *.mp4
13
+ *.log
@@ -0,0 +1,35 @@
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.
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.5
2
+ Name: wx-downloder
3
+ Version: 0.1.1
4
+ Summary: 通过元宝登录解析并下载微信视频号分享链接的 Python CLI
5
+ Project-URL: Homepage, https://github.com/QinGeneral/wx-downloder
6
+ Project-URL: Repository, https://github.com/QinGeneral/wx-downloder
7
+ Project-URL: Issues, https://github.com/QinGeneral/wx-downloder/issues
8
+ License: "Commons Clause" License Condition v1.0
9
+
10
+ License: MIT License
11
+ Licensor: ltaoo
12
+ 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)
13
+
14
+ The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
15
+
16
+ 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.
17
+
18
+ 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.
19
+
20
+ ---
21
+
22
+ MIT License
23
+
24
+ Copyright (c) 2025 ltaoo
25
+
26
+ Permission is hereby granted, free of charge, to any person obtaining a copy
27
+ of this software and associated documentation files (the "Software"), to deal
28
+ in the Software without restriction, including without limitation the rights
29
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
30
+ copies of the Software, and to permit persons to whom the Software is
31
+ furnished to do so, subject to the following conditions:
32
+
33
+ The above copyright notice and this permission notice shall be included in all
34
+ copies or substantial portions of the Software.
35
+
36
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
37
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
38
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
39
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
40
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
41
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
42
+ SOFTWARE.
43
+ License-File: LICENSE
44
+ Requires-Python: >=3.10
45
+ Requires-Dist: httpx<1,>=0.28
46
+ Requires-Dist: playwright<2,>=1.51
47
+ Description-Content-Type: text/markdown
48
+
49
+ # wx-downloder
50
+
51
+ 独立 Python CLI:打开腾讯元宝登录、保存登录凭据、解析微信视频号分享链接并下载 MP4。基于原项目 [wx_channels_download](https://github.com/ltaoo/wx_channels_download) 的元宝分享链接下载流程迁移;运行时无需 Go、微信客户端、代理抓包服务或 Cloudflare Worker。
52
+
53
+ ## 安装
54
+
55
+ 需要 Python 3.10+,以及 Chrome 或 Playwright Chromium。
56
+
57
+ ```bash
58
+ uv tool install wx-downloder
59
+ wx-downloder --help
60
+ ```
61
+
62
+ 固定安装本次版本:`uv tool install wx-downloder==0.1.1`。也支持 `python -m pip install wx-downloder`。
63
+
64
+ 如未安装 Chrome,安装 Playwright Chromium:
65
+
66
+ ```bash
67
+ uv tool run --from playwright playwright install chromium
68
+ ```
69
+
70
+ 源码开发安装:
71
+
72
+ ```bash
73
+ git clone https://github.com/QinGeneral/wx-downloder.git
74
+ cd wx-downloder
75
+ uv sync
76
+ uv run wx-downloder --help
77
+ ```
78
+
79
+ 从旧版 `wx-channels` 更新:先安装新版并确认 `wx-downloder --version` 输出 `0.1.1`,再运行 `uv tool uninstall wx-channels-cli` 移除旧命令。配置目录、`WX_CHANNELS_HOME` 环境变量和登录凭据位置保持兼容,已经登录的用户无需再次扫码。
80
+
81
+ 0.1.1 修复登录成功后浏览器关闭时请求回调反复抛出 `TargetClosedError`:请求监听使用本地缓存的请求头,并在关闭前解除监听。
82
+
83
+ 项目默认使用清华 PyPI 镜像以改善国内安装速度;需要官方源时可执行 `uv sync --default-index https://pypi.org/simple`。
84
+
85
+ ## 登录和下载
86
+
87
+ ```bash
88
+ # 启动独立浏览器窗口,扫码或手机号登录,检测到凭据后自动保存
89
+ wx-downloder login
90
+
91
+ # 单个链接;未保存登录凭据时会自动打开登录窗口
92
+ wx-downloder download 'https://weixin.qq.com/sph/实际分享ID'
93
+
94
+ # 多个链接 / JSON 文件 / 内联 JSON
95
+ wx-downloder download 'https://weixin.qq.com/sph/ID1' 'https://weixin.qq.com/sph/ID2'
96
+ wx-downloder download --input examples/links.json
97
+ wx-downloder download links.json
98
+ wx-downloder download '["https://weixin.qq.com/sph/ID1", {"url":"https://weixin.qq.com/sph/ID2"}]'
99
+ cat links.json | wx-downloder download - --no-login --json
100
+
101
+ # 不传参数:终端交互输入;管道输入则读取标准输入
102
+ wx-downloder download
103
+ ```
104
+
105
+ 必须使用微信分享生成的 `https://weixin.qq.com/sph/...` 链接。会自动去除分享参数和重复项。暂不支持公众号文章、直播、图集或微信内部短口令。
106
+
107
+ JSON 支持字符串数组、带 `url` / `share_url` / `shareUrl` / `sourceUrl` / `link` 字段的对象,以及 `links` / `urls` / `videos` / `items` / `data` 包装的列表。文件支持 UTF-8 和 UTF-8 BOM。示例链接需替换成真实链接。开始下载前验证全部输入,避免错格式时弹出浏览器。
108
+
109
+ ## 目录配置
110
+
111
+ ```bash
112
+ # 成品目录,默认 ~/Downloads/wx-channels
113
+ wx-downloder config set download_dir '/Volumes/Data/微信视频'
114
+
115
+ # 临时下载缓存目录,默认 ~/.cache/wx-channels(遵循 XDG_CACHE_HOME)
116
+ wx-downloder config set cache_dir '/Volumes/Data/视频缓存'
117
+
118
+ # 单次覆盖配置;支持缓存和成品在不同磁盘
119
+ wx-downloder download --input links.json --output './videos' --cache-dir './cache'
120
+
121
+ wx-downloder config set quality h265
122
+ wx-downloder config set timeout 120
123
+ wx-downloder config set retries 2
124
+ wx-downloder config set browser chrome
125
+ wx-downloder config show
126
+ wx-downloder status
127
+ wx-downloder logout
128
+ ```
129
+
130
+ `quality` 默认为 h264;优先所选编码,缺失时尝试通用地址和另一编码。`timeout` 是单次网络读写超时(秒),`retries` 是网络错误、HTTP 429/5xx 的重试次数(0–10)。重试重新解析链接并重新下载,不续传。批量任务中某个视频失败会继续后续项,并返回退出码 1;全部下载或跳过成功返回 0;取消返回 130。
131
+
132
+ 视频先写入缓存目录 `.part` 文件,校验长度和 MP4 文件头后再保存成品。正常结束、失败或 Ctrl+C 时清理本次临时文件。强制终止进程留下的 `.part` 可手动删除。已有文件不会覆盖;匹配下载记录且大小一致的文件自动跳过。成品名称包含视频标题和链接哈希,避免同名冲突;成品目录中的 `.wx-*.json` 保存标题、作者和去重记录,请保留。
133
+
134
+ ## 登录凭据
135
+
136
+ 元宝接口实际使用完整 Cookie,而非单个 token。CLI 在独立浏览器上下文中等待 `hy_user` 和 `hy_token`,保存适用于元宝的 Cookie、浏览器登录状态和相关请求头。保存后关闭本次浏览器,不读取日常浏览器配置文件。
137
+
138
+ 配置默认位于 `~/.config/wx-channels/config.json`,凭据位于同目录的 `auth.json`。可用 `WX_CHANNELS_HOME` 指定其他配置目录。凭据原子写入,文件权限为 `0600`,不打印到终端;下载 CDN 和视频号预览请求不携带元宝 Cookie。`logout` 删除本地凭据,不注销服务器会话。`status` 只反映本地保存情况;服务端凭据过期需重新 `login`。
139
+
140
+ 使用 [Playwright 浏览器登录状态保存机制](https://playwright.dev/python/docs/auth)。元宝和视频号接口并非稳定公开 API,接口或登录方式变化时可能需要更新。真实扫码必须由用户完成;自动测试使用模拟接口,不等同于真实账号端到端验证。
141
+
142
+ ## 开发验证
143
+
144
+ ```bash
145
+ uv sync
146
+ uv run pytest
147
+ uv run ruff check .
148
+ uv build
149
+
150
+ # 可选:真实 Chrome 回归测试,模拟登录成功后仍有请求进行的场景
151
+ WX_DOWNLODER_BROWSER_TEST=1 uv run pytest tests/test_browser_login.py -q
152
+ ```
153
+
154
+ 原项目许可证为 MIT + Commons Clause,保留在 `LICENSE`;本项目沿用该许可条件。
155
+
156
+ 验收记录:39 项常规自动化测试和 1 项真实 Chrome 登录关闭回归测试通过,Ruff 检查及格式检查通过,wheel/源码包构建通过。使用源项目已有凭据实测下载了一个 8,155,895 字节的视频,ffprobe 确认含 H.264 视频流和 AAC 音频流,时长 73.45 秒。该实测不替代用户首次扫码登录;新项目未预填源项目的凭据。
157
+
158
+ ## 发布流程
159
+
160
+ 发布使用 GitHub Actions 的 `.github/workflows/release.yml`,绑定 PyPI Trusted Publisher:`QinGeneral / wx-downloder / release.yml / pypi`。构建任务检查版本标签、运行测试、构建 wheel 和源码包并验证 CLI;独立发布任务通过 OIDC 上传,只有发布任务拥有 `id-token: write` 权限。
161
+
162
+ 维护者更新版本并提交后,创建与版本一致的 `v<版本>` 标签并推送即可发布。PyPI 版本不可覆盖。PyPI 页面:[wx-downloder](https://pypi.org/project/wx-downloder/)。
@@ -0,0 +1,114 @@
1
+ # wx-downloder
2
+
3
+ 独立 Python CLI:打开腾讯元宝登录、保存登录凭据、解析微信视频号分享链接并下载 MP4。基于原项目 [wx_channels_download](https://github.com/ltaoo/wx_channels_download) 的元宝分享链接下载流程迁移;运行时无需 Go、微信客户端、代理抓包服务或 Cloudflare Worker。
4
+
5
+ ## 安装
6
+
7
+ 需要 Python 3.10+,以及 Chrome 或 Playwright Chromium。
8
+
9
+ ```bash
10
+ uv tool install wx-downloder
11
+ wx-downloder --help
12
+ ```
13
+
14
+ 固定安装本次版本:`uv tool install wx-downloder==0.1.1`。也支持 `python -m pip install wx-downloder`。
15
+
16
+ 如未安装 Chrome,安装 Playwright Chromium:
17
+
18
+ ```bash
19
+ uv tool run --from playwright playwright install chromium
20
+ ```
21
+
22
+ 源码开发安装:
23
+
24
+ ```bash
25
+ git clone https://github.com/QinGeneral/wx-downloder.git
26
+ cd wx-downloder
27
+ uv sync
28
+ uv run wx-downloder --help
29
+ ```
30
+
31
+ 从旧版 `wx-channels` 更新:先安装新版并确认 `wx-downloder --version` 输出 `0.1.1`,再运行 `uv tool uninstall wx-channels-cli` 移除旧命令。配置目录、`WX_CHANNELS_HOME` 环境变量和登录凭据位置保持兼容,已经登录的用户无需再次扫码。
32
+
33
+ 0.1.1 修复登录成功后浏览器关闭时请求回调反复抛出 `TargetClosedError`:请求监听使用本地缓存的请求头,并在关闭前解除监听。
34
+
35
+ 项目默认使用清华 PyPI 镜像以改善国内安装速度;需要官方源时可执行 `uv sync --default-index https://pypi.org/simple`。
36
+
37
+ ## 登录和下载
38
+
39
+ ```bash
40
+ # 启动独立浏览器窗口,扫码或手机号登录,检测到凭据后自动保存
41
+ wx-downloder login
42
+
43
+ # 单个链接;未保存登录凭据时会自动打开登录窗口
44
+ wx-downloder download 'https://weixin.qq.com/sph/实际分享ID'
45
+
46
+ # 多个链接 / JSON 文件 / 内联 JSON
47
+ wx-downloder download 'https://weixin.qq.com/sph/ID1' 'https://weixin.qq.com/sph/ID2'
48
+ wx-downloder download --input examples/links.json
49
+ wx-downloder download links.json
50
+ wx-downloder download '["https://weixin.qq.com/sph/ID1", {"url":"https://weixin.qq.com/sph/ID2"}]'
51
+ cat links.json | wx-downloder download - --no-login --json
52
+
53
+ # 不传参数:终端交互输入;管道输入则读取标准输入
54
+ wx-downloder download
55
+ ```
56
+
57
+ 必须使用微信分享生成的 `https://weixin.qq.com/sph/...` 链接。会自动去除分享参数和重复项。暂不支持公众号文章、直播、图集或微信内部短口令。
58
+
59
+ JSON 支持字符串数组、带 `url` / `share_url` / `shareUrl` / `sourceUrl` / `link` 字段的对象,以及 `links` / `urls` / `videos` / `items` / `data` 包装的列表。文件支持 UTF-8 和 UTF-8 BOM。示例链接需替换成真实链接。开始下载前验证全部输入,避免错格式时弹出浏览器。
60
+
61
+ ## 目录配置
62
+
63
+ ```bash
64
+ # 成品目录,默认 ~/Downloads/wx-channels
65
+ wx-downloder config set download_dir '/Volumes/Data/微信视频'
66
+
67
+ # 临时下载缓存目录,默认 ~/.cache/wx-channels(遵循 XDG_CACHE_HOME)
68
+ wx-downloder config set cache_dir '/Volumes/Data/视频缓存'
69
+
70
+ # 单次覆盖配置;支持缓存和成品在不同磁盘
71
+ wx-downloder download --input links.json --output './videos' --cache-dir './cache'
72
+
73
+ wx-downloder config set quality h265
74
+ wx-downloder config set timeout 120
75
+ wx-downloder config set retries 2
76
+ wx-downloder config set browser chrome
77
+ wx-downloder config show
78
+ wx-downloder status
79
+ wx-downloder logout
80
+ ```
81
+
82
+ `quality` 默认为 h264;优先所选编码,缺失时尝试通用地址和另一编码。`timeout` 是单次网络读写超时(秒),`retries` 是网络错误、HTTP 429/5xx 的重试次数(0–10)。重试重新解析链接并重新下载,不续传。批量任务中某个视频失败会继续后续项,并返回退出码 1;全部下载或跳过成功返回 0;取消返回 130。
83
+
84
+ 视频先写入缓存目录 `.part` 文件,校验长度和 MP4 文件头后再保存成品。正常结束、失败或 Ctrl+C 时清理本次临时文件。强制终止进程留下的 `.part` 可手动删除。已有文件不会覆盖;匹配下载记录且大小一致的文件自动跳过。成品名称包含视频标题和链接哈希,避免同名冲突;成品目录中的 `.wx-*.json` 保存标题、作者和去重记录,请保留。
85
+
86
+ ## 登录凭据
87
+
88
+ 元宝接口实际使用完整 Cookie,而非单个 token。CLI 在独立浏览器上下文中等待 `hy_user` 和 `hy_token`,保存适用于元宝的 Cookie、浏览器登录状态和相关请求头。保存后关闭本次浏览器,不读取日常浏览器配置文件。
89
+
90
+ 配置默认位于 `~/.config/wx-channels/config.json`,凭据位于同目录的 `auth.json`。可用 `WX_CHANNELS_HOME` 指定其他配置目录。凭据原子写入,文件权限为 `0600`,不打印到终端;下载 CDN 和视频号预览请求不携带元宝 Cookie。`logout` 删除本地凭据,不注销服务器会话。`status` 只反映本地保存情况;服务端凭据过期需重新 `login`。
91
+
92
+ 使用 [Playwright 浏览器登录状态保存机制](https://playwright.dev/python/docs/auth)。元宝和视频号接口并非稳定公开 API,接口或登录方式变化时可能需要更新。真实扫码必须由用户完成;自动测试使用模拟接口,不等同于真实账号端到端验证。
93
+
94
+ ## 开发验证
95
+
96
+ ```bash
97
+ uv sync
98
+ uv run pytest
99
+ uv run ruff check .
100
+ uv build
101
+
102
+ # 可选:真实 Chrome 回归测试,模拟登录成功后仍有请求进行的场景
103
+ WX_DOWNLODER_BROWSER_TEST=1 uv run pytest tests/test_browser_login.py -q
104
+ ```
105
+
106
+ 原项目许可证为 MIT + Commons Clause,保留在 `LICENSE`;本项目沿用该许可条件。
107
+
108
+ 验收记录:39 项常规自动化测试和 1 项真实 Chrome 登录关闭回归测试通过,Ruff 检查及格式检查通过,wheel/源码包构建通过。使用源项目已有凭据实测下载了一个 8,155,895 字节的视频,ffprobe 确认含 H.264 视频流和 AAC 音频流,时长 73.45 秒。该实测不替代用户首次扫码登录;新项目未预填源项目的凭据。
109
+
110
+ ## 发布流程
111
+
112
+ 发布使用 GitHub Actions 的 `.github/workflows/release.yml`,绑定 PyPI Trusted Publisher:`QinGeneral / wx-downloder / release.yml / pypi`。构建任务检查版本标签、运行测试、构建 wheel 和源码包并验证 CLI;独立发布任务通过 OIDC 上传,只有发布任务拥有 `id-token: write` 权限。
113
+
114
+ 维护者更新版本并提交后,创建与版本一致的 `v<版本>` 标签并推送即可发布。PyPI 版本不可覆盖。PyPI 页面:[wx-downloder](https://pypi.org/project/wx-downloder/)。
@@ -0,0 +1,6 @@
1
+ {
2
+ "links": [
3
+ "https://weixin.qq.com/sph/REPLACE_WITH_SHARE_ID",
4
+ {"url": "https://weixin.qq.com/sph/REPLACE_WITH_ANOTHER_ID", "title": "可选备注,不参与文件名生成"}
5
+ ]
6
+ }
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wx-downloder"
7
+ version = "0.1.1"
8
+ description = "通过元宝登录解析并下载微信视频号分享链接的 Python CLI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {file = "LICENSE"}
12
+ dependencies = ["httpx>=0.28,<1", "playwright>=1.51,<2"]
13
+
14
+ [project.scripts]
15
+ wx-downloder = "wx_channels_cli.cli:main"
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/QinGeneral/wx-downloder"
19
+ Repository = "https://github.com/QinGeneral/wx-downloder"
20
+ Issues = "https://github.com/QinGeneral/wx-downloder/issues"
21
+
22
+ [dependency-groups]
23
+ dev = ["pytest>=8,<10", "ruff>=0.11"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/wx_channels_cli"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["/src", "/tests", "/examples", "/README.md", "/LICENSE", "/pyproject.toml", "/uv.lock"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+
34
+ [tool.ruff]
35
+ target-version = "py310"
36
+ line-length = 100
37
+
38
+ [[tool.uv.index]]
39
+ name = "tuna"
40
+ url = "https://pypi.tuna.tsinghua.edu.cn/simple"
41
+ default = true
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from urllib.parse import urlsplit
5
+
6
+ from .client import ORIGIN
7
+ from .config import AppError, app_home, read_json, write_json
8
+
9
+
10
+ def cookie_header(cookies: list[dict]) -> str:
11
+ now = time.time()
12
+ valid = []
13
+ for cookie in cookies:
14
+ domain = cookie.get("domain", "").lstrip(".")
15
+ expiry = cookie.get("expires", -1)
16
+ if (
17
+ domain
18
+ and ("yuanbao.tencent.com" == domain or "yuanbao.tencent.com".endswith("." + domain))
19
+ and (expiry == -1 or expiry > now)
20
+ and cookie.get("value")
21
+ and "/api/weixin/get_parse_result".startswith(cookie.get("path", "/"))
22
+ ):
23
+ valid.append(cookie)
24
+ valid.sort(key=lambda c: len(c.get("path", "/")), reverse=True)
25
+ return "; ".join(f"{c['name']}={c['value']}" for c in valid)
26
+
27
+
28
+ def load_auth() -> dict:
29
+ auth = read_json(app_home() / "auth.json", {})
30
+ if not isinstance(auth, dict):
31
+ raise AppError("登录凭据文件格式错误,请运行 wx-downloder login")
32
+ cookie = cookie_header(auth.get("cookies", []))
33
+ names = {part.split("=", 1)[0] for part in cookie.split("; ")}
34
+ if not cookie or not {"hy_user", "hy_token"}.issubset(names):
35
+ raise AppError("没有可用的元宝登录凭据,请先运行 wx-downloder login")
36
+ return auth | {"cookie": cookie}
37
+
38
+
39
+ def login(browser_name: str = "auto", timeout: int = 300, emit=print) -> None:
40
+ from playwright.sync_api import Error as BrowserError
41
+ from playwright.sync_api import sync_playwright
42
+
43
+ with sync_playwright() as pw:
44
+ browser = None
45
+ choices = ("chrome", "chromium") if browser_name == "auto" else (browser_name,)
46
+ for choice in choices:
47
+ try:
48
+ browser = pw.chromium.launch(
49
+ headless=False, **({"channel": choice} if choice != "chromium" else {})
50
+ )
51
+ break
52
+ except BrowserError:
53
+ continue
54
+ if browser is None:
55
+ raise AppError(
56
+ "无法启动浏览器。请安装 Chrome,或运行 python -m playwright install chromium"
57
+ )
58
+ context = None
59
+ capture = None
60
+ try:
61
+ # Always authenticate afresh: reusing locally unexpired cookies here can
62
+ # immediately re-save a session that the server has already revoked.
63
+ context = browser.new_context(locale="zh-CN")
64
+ page = context.new_page()
65
+ captured = {}
66
+
67
+ def capture(request):
68
+ if (
69
+ urlsplit(request.url).netloc != "yuanbao.tencent.com"
70
+ or "/api/" not in request.url
71
+ ):
72
+ return
73
+ # This property reads the event's cached headers without issuing a
74
+ # browser RPC. all_headers() can outlive the page during shutdown.
75
+ for key, value in request.headers.items():
76
+ if key in (
77
+ "authorization",
78
+ "t-userid",
79
+ "x-id",
80
+ "x-device-id",
81
+ "x-hy92",
82
+ "x-hy93",
83
+ ):
84
+ captured[key] = value
85
+
86
+ context.on("request", capture)
87
+ page.goto(ORIGIN, wait_until="domcontentloaded", timeout=60000)
88
+ emit("已打开元宝,请在浏览器中完成扫码或手机号登录;检测到登录凭据后自动保存。")
89
+ # Site labels vary; failure to auto-open is harmless, user can click 登录.
90
+ for label in ("登录", "立即登录", "登录 / 注册", "Log In", "Not logged in"):
91
+ try:
92
+ button = page.get_by_text(label, exact=True).first
93
+ if button.is_visible():
94
+ button.click(timeout=1500)
95
+ break
96
+ except BrowserError:
97
+ pass
98
+ deadline = time.monotonic() + timeout
99
+ while time.monotonic() < deadline:
100
+ if page.is_closed():
101
+ raise AppError("浏览器已关闭,未保存新的登录凭据")
102
+ cookies = context.cookies(ORIGIN + "/api/weixin/get_parse_result")
103
+ names = {
104
+ c["name"].lower(): c["value"]
105
+ for c in cookies
106
+ if c.get("value") and (c.get("expires", -1) == -1 or c["expires"] > time.time())
107
+ }
108
+ # Yuanbao creates anonymous tracking cookies before login; those do not count.
109
+ if names.get("hy_user") and names.get("hy_token"):
110
+ state = context.storage_state()
111
+ write_json(
112
+ app_home() / "auth.json",
113
+ {
114
+ "cookies": cookies,
115
+ "storage_state": state,
116
+ "headers": captured,
117
+ "user_agent": page.evaluate("navigator.userAgent"),
118
+ "saved_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
119
+ },
120
+ )
121
+ emit("元宝登录凭据已保存(仅当前用户可读写)。")
122
+ return
123
+ page.wait_for_timeout(1000)
124
+ raise AppError("等待登录超时,未检测到 hy_user/hy_token,请重新运行 wx-downloder login")
125
+ except BrowserError as exc:
126
+ raise AppError("元宝浏览器登录失败,请检查网络后重试") from exc
127
+ finally:
128
+ if context is not None and capture is not None:
129
+ context.remove_listener("request", capture)
130
+ browser.close()