wx-downloder 0.1.1__py3-none-any.whl

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 @@
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()
wx_channels_cli/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .auth import load_auth, login
10
+ from .client import YuanbaoClient
11
+ from .config import AppError, app_home, load_config, validate_setting, write_json
12
+ from .download import download_one
13
+ from .inputs import parse_inputs
14
+
15
+
16
+ def parser() -> argparse.ArgumentParser:
17
+ root = argparse.ArgumentParser(prog="wx-downloder", description="元宝登录 · 微信视频号下载")
18
+ root.add_argument("--version", action="version", version=__version__)
19
+ subs = root.add_subparsers(dest="command", required=True)
20
+ auth = subs.add_parser("login", help="打开元宝浏览器登录并保存凭据")
21
+ auth.add_argument("--browser", choices=("auto", "chrome", "chromium", "msedge"))
22
+ auth.add_argument("--timeout", type=int, default=300, help="扫码等待秒数,默认 300")
23
+ subs.add_parser("logout", help="删除本地保存的登录凭据")
24
+ subs.add_parser("status", help="查看本地凭据与目录状态,不输出 token")
25
+ config = subs.add_parser("config", help="查看和修改配置").add_subparsers(
26
+ dest="action", required=True
27
+ )
28
+ config.add_parser("show", help="查看配置")
29
+ setter = config.add_parser("set", help="设置配置项")
30
+ setter.add_argument("key")
31
+ setter.add_argument("value")
32
+ down = subs.add_parser("download", help="下载分享链接、JSON 列表或 JSON 文件")
33
+ down.add_argument("sources", nargs="*", help="链接、内联 JSON 或 JSON 文件路径;- 读取标准输入")
34
+ down.add_argument("--input", "-i", action="append", default=[], metavar="JSON_FILE")
35
+ down.add_argument("--output", "-o", help="本次下载成品目录")
36
+ down.add_argument("--cache-dir", help="本次下载临时缓存目录")
37
+ down.add_argument("--quality", choices=("h264", "h265"))
38
+ down.add_argument("--no-login", action="store_true", help="未登录时直接失败,不打开浏览器")
39
+ down.add_argument(
40
+ "--json", action="store_true", help="stdout 仅输出 JSON 结果,日志发往 stderr"
41
+ )
42
+ return root
43
+
44
+
45
+ def collect_sources(args) -> list[str]:
46
+ entries = []
47
+ stdin_used = False
48
+
49
+ def read_source(value: str, required_file=False):
50
+ nonlocal stdin_used
51
+ if value == "-":
52
+ if stdin_used:
53
+ raise AppError("标准输入只能读取一次")
54
+ stdin_used = True
55
+ return sys.stdin.read()
56
+ if not required_file and (
57
+ value.startswith("https://") or value.lstrip().startswith(("[", "{", '"'))
58
+ ):
59
+ return value
60
+ file = Path(value).expanduser()
61
+ if required_file or file.is_file():
62
+ try:
63
+ return file.read_text(encoding="utf-8-sig")
64
+ except (OSError, UnicodeError) as exc:
65
+ raise AppError(f"无法读取输入文件:{file}") from exc
66
+ return value
67
+
68
+ for value in args.sources:
69
+ entries.extend(parse_inputs(read_source(value)))
70
+ for file in args.input:
71
+ entries.extend(parse_inputs(read_source(file, True)))
72
+ if not args.sources and not args.input:
73
+ if sys.stdin.isatty():
74
+ print("请输入视频分享链接或 JSON 列表:", file=sys.stderr)
75
+ entries.extend(parse_inputs(input()))
76
+ else:
77
+ entries.extend(parse_inputs(sys.stdin.read()))
78
+ return list(dict.fromkeys(entries))
79
+
80
+
81
+ def run(args) -> int:
82
+ config = load_config()
83
+ emit = lambda message: print(message, file=sys.stderr)
84
+ if args.command == "config":
85
+ if args.action == "set":
86
+ config[args.key] = validate_setting(args.key, args.value)
87
+ write_json(app_home() / "config.json", config)
88
+ print(json.dumps(config, ensure_ascii=False, indent=2))
89
+ elif args.command == "login":
90
+ if args.timeout <= 0:
91
+ raise AppError("等待登录超时必须大于 0")
92
+ login(args.browser or config["browser"], args.timeout, emit)
93
+ elif args.command == "logout":
94
+ (app_home() / "auth.json").unlink(missing_ok=True)
95
+ print("本地元宝登录凭据已删除。")
96
+ elif args.command == "status":
97
+ try:
98
+ auth = load_auth()
99
+ status = "已保存(服务端有效性将在下载时检查)"
100
+ saved = auth.get("saved_at")
101
+ except AppError:
102
+ status, saved = "未登录或本地 Cookie 已过期", None
103
+ print(
104
+ json.dumps(
105
+ {"auth": status, "saved_at": saved, "config_dir": str(app_home()), **config},
106
+ ensure_ascii=False,
107
+ indent=2,
108
+ )
109
+ )
110
+ elif args.command == "download":
111
+ sources = collect_sources(args) # Validate all input before launching login/network calls.
112
+ for key, value in (
113
+ ("download_dir", args.output),
114
+ ("cache_dir", args.cache_dir),
115
+ ("quality", args.quality),
116
+ ):
117
+ if value is not None:
118
+ config[key] = validate_setting(key, value)
119
+ try:
120
+ auth = load_auth()
121
+ except AppError:
122
+ if args.no_login:
123
+ raise
124
+ login(config["browser"], emit=emit)
125
+ auth = load_auth()
126
+ results = []
127
+ with YuanbaoClient(auth, config["timeout"]) as client:
128
+ for index, source in enumerate(sources, 1):
129
+ emit(f"[{index}/{len(sources)}] {source}")
130
+
131
+ def progress(size, total):
132
+ percent = f" / {total / 1048576:.1f} MiB ({size / total:.0%})" if total else ""
133
+ emit(f" 已下载 {size / 1048576:.1f} MiB{percent}")
134
+
135
+ try:
136
+ result = download_one(source, client, config, progress)
137
+ emit(
138
+ f" {'跳过已下载' if result['status'] == 'skipped' else '已保存'}:{result['path']}"
139
+ )
140
+ except (AppError, OSError) as exc:
141
+ result = {"status": "failed", "source_url": source, "error": str(exc)}
142
+ emit(f" 失败:{exc}")
143
+ results.append(result)
144
+ counts = {
145
+ kind: sum(r["status"] == kind for r in results)
146
+ for kind in ("downloaded", "skipped", "failed")
147
+ }
148
+ if args.json:
149
+ print(json.dumps({"summary": counts, "results": results}, ensure_ascii=False, indent=2))
150
+ else:
151
+ print(
152
+ f"完成:下载 {counts['downloaded']},跳过 {counts['skipped']},失败 {counts['failed']}"
153
+ )
154
+ return 1 if counts["failed"] else 0
155
+ return 0
156
+
157
+
158
+ def main(argv=None) -> int:
159
+ args = parser().parse_args(argv)
160
+ try:
161
+ return run(args)
162
+ except KeyboardInterrupt:
163
+ print("\n已取消。", file=sys.stderr)
164
+ return 130
165
+ except (AppError, OSError, EOFError) as exc:
166
+ print(f"错误:{exc}", file=sys.stderr)
167
+ return 1
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import time
5
+ from urllib.parse import parse_qs, urlencode, urlsplit
6
+
7
+ import httpx
8
+
9
+ from .config import AppError
10
+ from .inputs import normalize_url
11
+
12
+ ORIGIN = "https://yuanbao.tencent.com"
13
+ PARSE_URL = ORIGIN + "/api/weixin/get_parse_result"
14
+ PREVIEW = "https://channels.weixin.qq.com/finder-preview"
15
+ USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
16
+
17
+
18
+ def object_field(data: dict, key: str) -> dict:
19
+ value = data.get(key)
20
+ return value if isinstance(value, dict) else {}
21
+
22
+
23
+ def http_url(value: str):
24
+ try:
25
+ url = urlsplit(value)
26
+ if url.scheme not in ("http", "https") or not url.netloc or url.username:
27
+ raise ValueError
28
+ return url
29
+ except ValueError as exc:
30
+ raise AppError("接口返回无效的视频地址") from exc
31
+
32
+
33
+ def json_response(response: httpx.Response, stage: str) -> dict:
34
+ if not 200 <= response.status_code < 300:
35
+ hint = ",请运行 wx-downloder login 重新登录" if response.status_code in (401, 403) else ""
36
+ raise AppError(f"{stage}返回 HTTP {response.status_code}{hint}")
37
+ try:
38
+ data = response.json()
39
+ except ValueError as exc:
40
+ raise AppError(f"{stage}返回非 JSON 数据") from exc
41
+ if not isinstance(data, dict):
42
+ raise AppError(f"{stage}返回格式异常")
43
+ return data
44
+
45
+
46
+ class YuanbaoClient:
47
+ def __init__(self, auth: dict, timeout: int = 60, transport=None):
48
+ self.auth = auth
49
+ # Cookies are attached ONLY to the Yuanbao POST, never as client defaults.
50
+ self.http = httpx.Client(timeout=timeout, transport=transport, follow_redirects=False)
51
+
52
+ def __enter__(self):
53
+ return self
54
+
55
+ def __exit__(self, *args):
56
+ self.http.close()
57
+
58
+ def resolve(self, source: str, quality: str = "h264") -> dict:
59
+ source = normalize_url(source)
60
+ headers = {
61
+ "User-Agent": self.auth.get("user_agent") or USER_AGENT,
62
+ "Origin": ORIGIN,
63
+ "Referer": ORIGIN + "/",
64
+ "Accept": "application/json",
65
+ "x-source": "web",
66
+ "Cookie": self.auth["cookie"],
67
+ }
68
+ for key, value in self.auth.get("headers", {}).items():
69
+ if key in ("authorization", "t-userid", "x-id", "x-device-id", "x-hy92", "x-hy93"):
70
+ headers[key] = value
71
+ result = json_response(
72
+ self.http.post(
73
+ PARSE_URL,
74
+ headers=headers,
75
+ json={
76
+ "type": "video_channel_url",
77
+ "url": source,
78
+ "scene": 1,
79
+ },
80
+ ),
81
+ "元宝解析接口",
82
+ )
83
+ if result.get("code") != 0:
84
+ raise AppError(f"元宝解析失败(code={result.get('code')}),请重新登录并检查链接")
85
+ parsed = result.get("data")
86
+ if not isinstance(parsed, dict) or not isinstance(parsed.get("playable_url"), str):
87
+ raise AppError("元宝未返回视频预览地址")
88
+ url = http_url(parsed["playable_url"])
89
+ query = parse_qs(url.query)
90
+ if (
91
+ url.scheme != "https"
92
+ or url.netloc != "channels.weixin.qq.com"
93
+ or not all(query.get(k) for k in ("token", "eid"))
94
+ ):
95
+ raise AppError("元宝未返回有效的预览 token/eid")
96
+ params = {
97
+ "_rid": f"{int(time.time()):x}-{secrets.token_hex(4)}",
98
+ "_pageUrl": PREVIEW + "/pages/feed",
99
+ }
100
+ profile = json_response(
101
+ self.http.post(
102
+ PREVIEW + "/api/feed/get_feed_info?" + urlencode(params),
103
+ headers={
104
+ "User-Agent": headers["User-Agent"],
105
+ "Origin": "https://channels.weixin.qq.com",
106
+ "Referer": parsed["playable_url"],
107
+ },
108
+ json={"baseReq": {"generalToken": query["token"][0]}, "exportId": query["eid"][0]},
109
+ ),
110
+ "视频号预览接口",
111
+ )
112
+ data = profile.get("data")
113
+ error = object_field(data, "errMsg") if isinstance(data, dict) else {}
114
+ if (
115
+ profile.get("errCode", 0) != 0
116
+ or not isinstance(data, dict)
117
+ or any(error.get(k) for k in ("type", "title", "content"))
118
+ ):
119
+ raise AppError("视频号预览失败,视频可能已删除、不可见或预览凭证失效")
120
+ feed = data.get("feedInfo")
121
+ if not isinstance(feed, dict) or feed.get("mediaType") != 4:
122
+ raise AppError("链接不是可下载的普通视频,暂不支持图集或直播")
123
+ candidates = [quality, "h265" if quality == "h264" else "h264"]
124
+ media = object_field(feed, candidates[0] + "VideoInfo").get("videoUrl")
125
+ media = (
126
+ media
127
+ or feed.get("videoUrl")
128
+ or object_field(feed, candidates[1] + "VideoInfo").get("videoUrl")
129
+ )
130
+ if not isinstance(media, str):
131
+ raise AppError("视频详情缺少有效的媒体下载地址")
132
+ http_url(media)
133
+ return {
134
+ "source_url": source,
135
+ "media_url": media,
136
+ "description": str(feed.get("description") or parsed.get("desc") or "video"),
137
+ "author": str(
138
+ object_field(data, "authorInfo").get("nickname") or parsed.get("author") or ""
139
+ ),
140
+ "profile": profile,
141
+ }
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ class AppError(Exception):
10
+ """可直接向用户展示的错误。"""
11
+
12
+
13
+ def app_home() -> Path:
14
+ return Path(os.environ.get("WX_CHANNELS_HOME", "~/.config/wx-channels")).expanduser().resolve()
15
+
16
+
17
+ def read_json(path: Path, default=None):
18
+ if not path.exists():
19
+ return default
20
+ try:
21
+ return json.loads(path.read_text(encoding="utf-8-sig"))
22
+ except (ValueError, UnicodeError) as exc:
23
+ raise AppError(f"JSON 文件无效:{path}") from exc
24
+
25
+
26
+ def write_json(path: Path, data) -> None:
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ fd, temp = tempfile.mkstemp(prefix=".wx-", suffix=".tmp", dir=path.parent)
29
+ try:
30
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
31
+ json.dump(data, file, ensure_ascii=False, indent=2)
32
+ file.write("\n")
33
+ file.flush()
34
+ os.fsync(file.fileno())
35
+ os.replace(temp, path)
36
+ finally:
37
+ Path(temp).unlink(missing_ok=True)
38
+
39
+
40
+ def defaults() -> dict:
41
+ return {
42
+ "download_dir": str(Path.home() / "Downloads" / "wx-channels"),
43
+ "cache_dir": str(
44
+ Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "wx-channels"
45
+ ),
46
+ "timeout": 60,
47
+ "retries": 2,
48
+ "quality": "h264",
49
+ "browser": "auto",
50
+ }
51
+
52
+
53
+ def validate_setting(key: str, value):
54
+ if key not in defaults():
55
+ raise AppError(f"未知配置项:{key}")
56
+ if key.endswith("_dir"):
57
+ if not isinstance(value, str) or not value.strip():
58
+ raise AppError(f"{key} 必须是非空路径")
59
+ return str(Path(value).expanduser().resolve())
60
+ if key in ("timeout", "retries"):
61
+ try:
62
+ number = int(value)
63
+ except (TypeError, ValueError) as exc:
64
+ raise AppError(f"{key} 必须是整数") from exc
65
+ if isinstance(value, bool) or str(number) != str(value) or not 0 <= number <= 3600:
66
+ raise AppError(f"{key} 必须是 0 到 3600 的整数")
67
+ if key == "timeout" and number == 0:
68
+ raise AppError("timeout 必须大于 0")
69
+ if key == "retries" and number > 10:
70
+ raise AppError("retries 最大为 10")
71
+ return number
72
+ options = {"quality": ("h264", "h265"), "browser": ("auto", "chrome", "chromium", "msedge")}
73
+ if value not in options[key]:
74
+ raise AppError(f"{key} 可选值:{', '.join(options[key])}")
75
+ return value
76
+
77
+
78
+ def load_config() -> dict:
79
+ saved = read_json(app_home() / "config.json", {})
80
+ if not isinstance(saved, dict):
81
+ raise AppError("config.json 必须是 JSON 对象")
82
+ return defaults() | {key: validate_setting(key, value) for key, value in saved.items()}
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import shutil
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+
13
+ from .client import USER_AGENT
14
+ from .config import AppError, read_json, write_json
15
+
16
+
17
+ def safe_name(value: str) -> str:
18
+ name = re.sub(r'[\x00-\x1f\x7f/\\:*?"<>|\s]+', "_", value).strip("._ ")[:60]
19
+ return name or "video"
20
+
21
+
22
+ def stream_video(url: str, cache_dir: Path, timeout: int, progress, transport=None) -> Path:
23
+ cache_dir.mkdir(parents=True, exist_ok=True)
24
+ fd, name = tempfile.mkstemp(prefix="wx-video-", suffix=".part", dir=cache_dir)
25
+ path = Path(name)
26
+ complete = False
27
+ try:
28
+ with (
29
+ os.fdopen(fd, "wb") as output,
30
+ httpx.Client(
31
+ timeout=timeout,
32
+ follow_redirects=True,
33
+ transport=transport,
34
+ headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"},
35
+ ) as http,
36
+ http.stream("GET", url) as response,
37
+ ):
38
+ if response.status_code != 200:
39
+ raise AppError(f"视频下载返回 HTTP {response.status_code}")
40
+ content_type = response.headers.get("content-type", "").lower()
41
+ if any(kind in content_type for kind in ("text/", "json", "xml", "mpegurl")):
42
+ raise AppError("媒体地址返回网页或错误信息,未保存为视频")
43
+ try:
44
+ total = int(response.headers.get("content-length", "0"))
45
+ except ValueError as exc:
46
+ raise AppError("媒体服务器返回无效的文件长度") from exc
47
+ size, last = 0, 0.0
48
+ head = bytearray()
49
+ for chunk in response.iter_bytes(256 * 1024):
50
+ if len(head) < 32:
51
+ head.extend(chunk[: 32 - len(head)])
52
+ output.write(chunk)
53
+ size += len(chunk)
54
+ now = time.monotonic()
55
+ if now - last >= 1:
56
+ progress(size, total)
57
+ last = now
58
+ if not size or (total and size != total):
59
+ raise AppError(f"视频传输不完整:收到 {size} 字节,预期 {total}")
60
+ if bytes(head[4:8]) not in (b"ftyp", b"moov", b"mdat", b"free", b"wide"):
61
+ raise AppError("下载内容不是有效的 MP4 文件,未保存成品")
62
+ output.flush()
63
+ os.fsync(output.fileno())
64
+ progress(size, total)
65
+ complete = True
66
+ return path
67
+ finally:
68
+ if not complete:
69
+ path.unlink(missing_ok=True)
70
+
71
+
72
+ def publish(temp: Path, target: Path) -> None:
73
+ """Cross-filesystem safe; never replace existing files, clean interrupted copies."""
74
+ created = False
75
+ try:
76
+ with target.open("xb") as out:
77
+ created = True
78
+ with temp.open("rb") as source:
79
+ shutil.copyfileobj(source, out, 1024 * 1024)
80
+ out.flush()
81
+ os.fsync(out.fileno())
82
+ except BaseException:
83
+ if created:
84
+ target.unlink(missing_ok=True)
85
+ raise
86
+
87
+
88
+ def download_one(
89
+ source: str, resolver, config: dict, progress=lambda *_: None, transport=None
90
+ ) -> dict:
91
+ directory = Path(config["download_dir"]).expanduser().resolve()
92
+ cache = Path(config["cache_dir"]).expanduser().resolve()
93
+ directory.mkdir(parents=True, exist_ok=True)
94
+ identity = hashlib.sha256(source.encode()).hexdigest()[:12]
95
+ receipt = directory / f".wx-{identity}.json"
96
+ previous = read_json(receipt, {})
97
+ if isinstance(previous, dict) and previous.get("source_url") == source:
98
+ filename = previous.get("filename", "")
99
+ if filename and Path(filename).name == filename:
100
+ target = directory / filename
101
+ if target.is_file() and target.stat().st_size == previous.get("bytes", 0) > 0:
102
+ return {
103
+ "status": "skipped",
104
+ "source_url": source,
105
+ "path": str(target),
106
+ "bytes": target.stat().st_size,
107
+ }
108
+ for attempt in range(config["retries"] + 1):
109
+ temp = None
110
+ try:
111
+ video = resolver.resolve(source, config["quality"])
112
+ filename = f"{safe_name(video['description'])}-{identity}.mp4"
113
+ target = directory / filename
114
+ if target.exists():
115
+ raise AppError(f"目标文件已存在但无匹配下载记录,请移动或重命名后重试:{target}")
116
+ temp = stream_video(video["media_url"], cache, config["timeout"], progress, transport)
117
+ publish(temp, target)
118
+ result = {
119
+ "status": "downloaded",
120
+ "source_url": source,
121
+ "path": str(target),
122
+ "bytes": target.stat().st_size,
123
+ "filename": filename,
124
+ "description": video["description"],
125
+ "author": video["author"],
126
+ }
127
+ try:
128
+ write_json(receipt, result)
129
+ except OSError as exc:
130
+ raise AppError(f"视频已保存到 {target},但下载记录保存失败") from exc
131
+ return result
132
+ except (httpx.TransportError, AppError) as exc:
133
+ # Retry network/temporary failures, never repeatedly retry login or invalid input.
134
+ retryable = (
135
+ isinstance(exc, httpx.TransportError)
136
+ or "HTTP 5" in str(exc)
137
+ or "HTTP 429" in str(exc)
138
+ )
139
+ if not retryable or attempt == config["retries"]:
140
+ if isinstance(exc, httpx.TransportError):
141
+ raise AppError("网络请求失败或超时,请检查网络后重试") from exc
142
+ raise
143
+ time.sleep(min(2**attempt, 8))
144
+ finally:
145
+ if temp is not None:
146
+ temp.unlink(missing_ok=True)
147
+ raise AssertionError("unreachable")
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from urllib.parse import urlsplit, urlunsplit
6
+
7
+ from .config import AppError
8
+
9
+
10
+ def normalize_url(value: str) -> str:
11
+ try:
12
+ url = urlsplit(value.strip())
13
+ if (
14
+ url.scheme != "https"
15
+ or url.netloc != "weixin.qq.com"
16
+ or not re.fullmatch(r"/sph/[A-Za-z0-9_-]+/?", url.path)
17
+ ):
18
+ raise ValueError
19
+ except ValueError as exc:
20
+ raise AppError("请输入 https://weixin.qq.com/sph/... 格式的视频号分享链接") from exc
21
+ return urlunsplit((url.scheme, url.netloc, url.path.rstrip("/"), "", ""))
22
+
23
+
24
+ def parse_inputs(raw: str) -> list[str]:
25
+ """链接、字符串数组、对象数组,以及 links/urls/videos/items/data 包装对象。"""
26
+ raw = raw.strip()
27
+ if not raw:
28
+ raise AppError("输入为空")
29
+ try:
30
+ value = json.loads(raw) if raw[0] in '[{"' else raw
31
+ except ValueError as exc:
32
+ raise AppError("输入 JSON 格式无效") from exc
33
+
34
+ def walk(item):
35
+ if isinstance(item, str):
36
+ yield normalize_url(item)
37
+ elif isinstance(item, list):
38
+ for child in item:
39
+ yield from walk(child)
40
+ elif isinstance(item, dict):
41
+ for key in ("url", "share_url", "shareUrl", "sourceUrl", "link"):
42
+ if key in item:
43
+ yield from walk(item[key])
44
+ return
45
+ for key in ("links", "urls", "videos", "items", "data"):
46
+ if key in item:
47
+ yield from walk(item[key])
48
+ return
49
+ raise AppError("JSON 对象需要 url/share_url 字段或 links/urls/videos/items/data 列表")
50
+ else:
51
+ raise AppError("JSON 中的链接必须是字符串或带 url 字段的对象")
52
+
53
+ result = list(dict.fromkeys(walk(value)))
54
+ if not result:
55
+ raise AppError("输入列表中没有视频链接")
56
+ return result
@@ -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,13 @@
1
+ wx_channels_cli/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
2
+ wx_channels_cli/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ wx_channels_cli/auth.py,sha256=ePOoNN_EcQkCx0oOLLKoy4uScnuJNIviiS44BUau3lg,5790
4
+ wx_channels_cli/cli.py,sha256=DV2Oy6vz3jia0pnP2RrhFyopcym-mFBZ2WRBd7XwROI,6990
5
+ wx_channels_cli/client.py,sha256=K4CfIC0lpiYE1yguVimo9VfMaaAQSDa-itKseSVcThM,5506
6
+ wx_channels_cli/config.py,sha256=kOnC-m3vESH8jv-J0GVvvHacVi7Lo2Hfuw89ZGSWqvw,2786
7
+ wx_channels_cli/download.py,sha256=lLMzX-iyPj6f4xx5QfcWGcsZQA_JCqSjmm9HgvbFCjY,5936
8
+ wx_channels_cli/inputs.py,sha256=G4trv3nifkZU88wTrjoOO9g4Co1zJ-GT53ilC1im3Ks,1972
9
+ wx_downloder-0.1.1.dist-info/METADATA,sha256=yYYEsYJNvqPonqC36HnHTzwfug762Wd619LvnrfYbww,9343
10
+ wx_downloder-0.1.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
11
+ wx_downloder-0.1.1.dist-info/entry_points.txt,sha256=cye18qs5K7lR6ODRiv3fgjYTuT1XGrOVc0gm_H37bqY,58
12
+ wx_downloder-0.1.1.dist-info/licenses/LICENSE,sha256=5LGmB7V9qAUR0cb9p11EsclSo0y1W0PgxPJT6mCAFlE,2016
13
+ wx_downloder-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wx-downloder = wx_channels_cli.cli:main
@@ -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.