From eaacacc8e0367cb0c16c32a0b2f774c4c5dab643 Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 8 Oct 2026 17:19:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4:=E9=BB=84?= =?UTF-8?q?=E6=9E=9C=E7=9F=AD=E5=89=A7=E6=8A=93=E5=8C=85=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hls_dl.py: 通用 HLS 下载器(m3u8 解析/AES-128 解密/ffmpeg 合并) - reqable_mcp.py: Reqable MCP 客户端(stdio, --port 9002) - huangguo_video_dl.user.js: 油猴全集下载脚本 v1.1.2 * 详情页一键下载全部集数,解密转封装后打包 ZIP 存 ~/Downloads * 手写 STORE ZIP(逐集 Blob 卸堆外),修复 JSZip 大文件卡死 * @connect 覆盖 rlkfus.cn / cloudfront.net CDN - README.md: 用法与踩坑记录 --- .gitignore | 7 + README.md | 117 ++++++++++++ hls_dl.py | 245 ++++++++++++++++++++++++++ huangguo_video_dl.user.js | 361 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 12 ++ reqable_mcp.py | 121 +++++++++++++ uv.lock | 57 ++++++ 7 files changed, 920 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 hls_dl.py create mode 100644 huangguo_video_dl.user.js create mode 100644 pyproject.toml create mode 100644 reqable_mcp.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d73e8bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store +*.mp4 +*.zip +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..855693c --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# huangguo-video + +黄果(huangguo.me)短剧抓包下载工具。从 Reqable 抓包记录还原 AES-128 加密的 HLS 视频。 + +## 环境 + +- macOS + [uv](https://docs.astral.sh/uv/) 包管理 +- `ffmpeg`(合并分片用,系统已装) +- Reqable 3.2.7+(MCP 功能,可选) + +```bash +cd huangguo-video +uv sync # 自动建 .venv 并装依赖(pycryptodome) +``` + +> `pyproject.toml` 里固定了 `system-certs = true`:本机走 MITM 代理抓包,uv 自带证书库不认系统 CA,没这条 `uv sync` 会报 TLS 错。 + +## 文件说明 + +| 文件 | 用途 | +|---|---| +| `hls_dl.py` | 通用 HLS 下载器:拉 m3u8 → 并发下分片 → AES-128 解密 → ffmpeg 合并 | +| `reqable_mcp.py` | Reqable MCP 客户端:命令行翻抓包记录、调用 Reqable 的 50+ 个工具 | +| `huangguo_video_dl.user.js` | **油猴脚本**:详情页一键全集下载,打包 ZIP 存到 ~/Downloads | +| `陪玩假女友_1.mp4` | 成品:123s / 720×1280 / H.264+AAC / 21.93 MB | + +## 用法一:HLS 直接下载 + +```bash +# 整条流(m3u8 地址,从 Reqable 复制完整 URL) +uv run python hls_dl.py "https://xxx.m3u8?id=..&ticket=.." \ + --referer "https://huangguo.me/" -o 视频.mp4 + +# 单个 .ts 分片 +uv run python hls_dl.py x --ts-url "https://xxx/seg.ts?md=.." -o test.ts + +# 需要走代理时(默认直连) +uv run python hls_dl.py "..." --proxy http://127.0.0.1:7890 +``` + +要点: + +- **自动识别** master/media 多级播放列表,选最高码率 +- **AES-128 自动解密**,无 `IV` 时按 HLS 规范用 `EXT-X-MEDIA-SEQUENCE` 序号做 IV(0,1,2,…) +- 合并优先 `ffmpeg -f concat -c copy`(无损),没有 ffmpeg 回退二进制拼接 +- 单次 m3u8 可能只返回部分分片(分页/轮换),拿不到完整列表时走用法二 + +## 用法二:从 Reqable 记录还原视频 + +MCP 是 stdio 型子进程,`reqable_mcp.py` 封装了握手: + +```bash +# 列出所有 MCP 工具 +python3 reqable_mcp.py list + +# 调用任意工具 +python3 reqable_mcp.py call capture_live_get_by_id '{"id": 4289}' + +# 快捷搜索 +python3 reqable_mcp.py m3u8 +``` + +典型还原流程: + +```python +from reqable_mcp import McpClient +c = McpClient(); c.initialize() + +# 1. 按关键词搜记录,拿 ID 列表 +r = c.call('capture_live_filter', {'filters': [{'type': 'keyword', 'pattern': 'm3u8'}]}) +ids = json.loads(r['content'][0]['text']) + +# 2. 取详情:m3u8 响应体在 response.body.text,enkey 的 key 是 base64 +# 分片密文被 Reqable 落盘,response.body.text 是本地文件路径 +d = json.loads(c.call('capture_live_get_by_id', {'id': 4289})['content'][0]['text']) + +# 3. key + 序号 IV 解密 → ffmpeg 合并(见 git log 里的一次性脚本) +``` + +三件套:完整 **m3u8**(记录的响应体) + **AES key**(`/enkey` 响应,base64) + **分片密文**(Reqable 本地缓存文件,缺失的段用记录里的 URL 在线补)。 + +## 用法三:油猴脚本一键全集下载 + +`huangguo_video_dl.user.js` — 安装到 Tampermonkey/Violentmonkey 后,打开任意详情页 `https://huangguo.me/detail//`: + +1. 剧集区出现 **「⬇ 全集下载」** 按钮 +2. 点击 → 自动读取剧名、遍历全部集数、逐集下载解密转封装 → 打包 `剧名.zip` 存入 **~/Downloads** +3. ZIP 内文件名:`剧名_1.mp4`、`剧名_2.mp4` …(按 `data-ep` 排序) + +**实现要点**(全部在浏览器内完成,零后端): + +| 环节 | 方案 | +|---|---| +| 跨域请求 | `GM_xmlhttpRequest`(`@connect huangguo.me` + `*.rlkfus.cn`) | +| 剧名/集数 | `[data-drama-title]` / `data-total-episode` / `a.detail-episode[data-ep]` | +| m3u8 地址 | play 页 `.dplayer-video` 的 `data-url`,**拼 `/h5/m3u8/` 前缀** | +| AES-128 解密 | WebCrypto `subtle.decrypt`,IV = media sequence 大端 | +| TS → MP4 | `mux.js` `mp4.Transmuxer`(输出 fMP4) | +| 打包 | **手写 STORE ZIP**(每集 CRC32 后转 `Blob` 卸到堆外,只拼头尾字节) | +| 付费集 | 识别 `data-coin-dialog-open`,自动跳过并在完成提示里列出 | + +**已验证**(Node 仿真真实管线): +- 下载解密转封装:第 1 集 21.9MB/3.7s、第 2 集 14.5MB/2.4s,解压后文件名与时长均正确 +- 打包:10 集 × 20MB = 200MB 压测,**1 秒内完成**,Python `testzip()` CRC 逐字节全过,内存 RSS 294MB + +## 踩坑记录 + +1. **Reqable MCP 端口**:默认连 `127.0.0.1:9000`,但该端口被 adb 占了,Reqable 实际监听 **9002**,必须 `mcp-server --port 9002`(`reqable_mcp.py` 已写死)。 +2. **TLS 证书**:抓包环境有 MITM 证书,`uv`/`curl`/裸 `urllib` 都可能报 `CERTIFICATE_VERIFY_FAILED`。请求走 `hls_dl.http_get()`(显式不走代理)或设 `system-certs = true`。 +3. **无 IV ≠ 用 key 当 IV**:HLS 规范是用 media sequence 号大端填充 16 字节,搞错会花屏。 +4. **m3u8 地址短时效**:`ticket`/`md` 签名会过期,分片 URL 同理;完整的列表和分片**尽快落盘**(Reqable 的本地缓存就是保险)。 +5. **裸 `.ts` 报 Invalid data**:十有八九是 AES 密文,先找 `#EXT-X-KEY`。 +6. **m3u8 相对路径前缀**:play 页 `data-url` 是 `v3/...m3u8`,必须拼 `https://huangguo.me/h5/m3u8/`,拼成根路径直接 404。 +7. **接口不校验 Referer**:play 页 / m3u8 / 分片 / enkey 全链路去掉 Referer 照常(浏览器脚本本来也伪造不了),这是油猴方案可行的前提。 +8. **m3u8 单次即全集**:每次请求返回的就是该集完整列表(如第 1 集 18 段);之前遇到的"3 段广告"是另一个轮换位,别混淆。 +9. **`mux.js` 全局名**:浏览器 UMD 分支挂的是 **`muxjs`**(不是 `mux`,vm 实测),脚本里写 `window.muxjs || window.mux` 兼容;Node 加载需 `npm i global` 提供 shim,入口 `mux.mp4.Transmuxer`。 +10. **JSZip `generateAsync` 卡 90%**:Firefox 下多集大文件(每集 ~20MB×N)会永久挂死——所有 `Uint8Array` 压在 JS 堆上引发 GC 风暴。解法是弃用 JSZip,每集算完 CRC 立即 `new Blob([bytes])` 卸到堆外,ZIP 组装只拼 local header/central dir/EOCD 字节 + Blob 引用,200MB 1 秒完成。 diff --git a/hls_dl.py b/hls_dl.py new file mode 100644 index 0000000..5bc7817 --- /dev/null +++ b/hls_dl.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +HLS 拉流下载器(纯标准库,零依赖) +用法: + python3 hls_dl.py "https://huangguo.me/h5/m3u8/v3/bktappdata/sp/om/xxx.m3u8" -o out.mp4 + python3 hls_dl.py "https://xxx.m3u8" --referer https://huangguo.me/ --proxy http://127.0.0.1:7890 + python3 hls_dl.py "https://xxx.m3u8" --ts-url "https://dyum53hre43nj.cloudfront.net/.../xxx.ts?md=..." # 单个分片直接下 + +说明: +- 支持 master 播放列表(自动选最高码率)、多级嵌套 m3u8、相对路径拼接 +- AES-128 加密的流会自动尝试解密(需要 pycryptodome,没有则提示改用 ffmpeg) +- 合并优先调用 ffmpeg(需已安装);没有 ffmpeg 就二进制拼接 TS +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.request +import urllib.parse +from concurrent.futures import ThreadPoolExecutor, as_completed + +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36") + + +def build_opener(proxy=None): + handlers = [] + if proxy: + handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy})) + else: + handlers.append(urllib.request.ProxyHandler({})) # 不走系统代理 + return urllib.request.build_opener(*handlers) + + +def http_get(url, referer=None, proxy=None, timeout=30): + req = urllib.request.Request(url, headers={ + "User-Agent": UA, + "Referer": referer or "", + "Accept": "*/*", + }) + opener = build_opener(proxy) + with opener.open(req, timeout=timeout) as resp: + return resp.read() + + +def parse_m3u8(text, base_url): + """返回 (type, items) + type='master' -> items=[(bandwidth, uri)] + type='media' -> items=[{'uri':..., 'key':..., 'iv':...}, ...] + """ + lines = [l.strip() for l in text.splitlines() if l.strip()] + if not lines or lines[0] != "#EXTM3U": + raise ValueError("不是合法的 m3u8") + + if any(l.startswith("#EXT-X-STREAM-INF") for l in lines): + variants = [] + for i, l in enumerate(lines): + if l.startswith("#EXT-X-STREAM-INF"): + m = re.search(r"BANDWIDTH=(\d+)", l) + bw = int(m.group(1)) if m else 0 + # 下一个非注释行就是 uri + for j in range(i + 1, len(lines)): + if not lines[j].startswith("#"): + variants.append((bw, urllib.parse.urljoin(base_url, lines[j]))) + break + return "master", variants + + segments, cur_key, cur_iv = [], None, None + seq = 0 # EXT-X-MEDIA-SEQUENCE,无 IV 时用它做 AES IV + for l in lines: + if l.startswith("#EXT-X-MEDIA-SEQUENCE"): + seq = int(l.split(":", 1)[1]) + elif l.startswith("#EXT-X-KEY"): + m = re.search(r'URI="([^"]+)"', l) + iv = re.search(r"IV=(0x[0-9a-fA-F]+)", l) + cur_key = urllib.parse.urljoin(base_url, m.group(1)) if m else None + cur_iv = iv.group(1) if iv else None + elif l.startswith("#EXT-X-MAP"): + m = re.search(r'URI="([^"]+)"', l) + if m: # fMP4 的 init 段 + segments.append({"uri": urllib.parse.urljoin(base_url, m.group(1)), + "key": cur_key, "iv": cur_iv, "seq": seq, "init": True}) + elif not l.startswith("#"): + segments.append({"uri": urllib.parse.urljoin(base_url, l), + "key": cur_key, "iv": cur_iv, "seq": seq, "init": False}) + seq += 1 + return "media", segments + + +def aes_decrypt(data, key, iv_hex=None, seq=0): + try: + from Cryptodome.Cipher import AES # pycryptodome + except ImportError: + try: + from Crypto.Cipher import AES + except ImportError: + return None + if iv_hex: + iv = bytes.fromhex(iv_hex.replace("0x", "")) + else: + # HLS 规范:未给 IV 时,IV = 分片的 media sequence 号的 16 字节大端 + iv = seq.to_bytes(16, "big") + cipher = AES.new(key, AES.MODE_CBC, iv) + return cipher.decrypt(data) + + +_KEY_CACHE = {} + + +def fetch_key(key_url, referer, proxy): + if key_url not in _KEY_CACHE: + _KEY_CACHE[key_url] = http_get(key_url, referer, proxy) + return _KEY_CACHE[key_url] + + +def download_segment(seg, referer, proxy, retries=3): + for attempt in range(retries): + try: + data = http_get(seg["uri"], referer, proxy) + if seg.get("key"): + key = fetch_key(seg["key"], referer, proxy) + dec = aes_decrypt(data, key, seg.get("iv"), seg.get("seq", 0)) + if dec is None: + raise RuntimeError("AES 解密需要 pycryptodome,请 pip install pycryptodome") + data = dec + return data + except Exception as e: + if attempt == retries - 1: + raise RuntimeError(f"分片失败 {seg['uri']}: {e}") from e + return b"" + + +def merge(files, out_path): + ffmpeg = shutil.which("ffmpeg") + if ffmpeg: + # 用 concat demuxer,免去 -c copy 拼接时的时间戳问题 + list_file = out_path + ".list.txt" + with open(list_file, "w") as f: + for p in files: + f.write(f"file '{os.path.abspath(p)}'\n") + cmd = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", list_file, + "-c", "copy", out_path] + r = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + os.remove(list_file) + if r.returncode == 0: + return "ffmpeg" + print(f"[警告] ffmpeg 合并失败,回退二进制拼接:\n{r.stderr.decode()[-400:]}", file=sys.stderr) + # 二进制拼接(TS 天然支持) + with open(out_path, "wb") as out: + for p in files: + with open(p, "rb") as f: + shutil.copyfileobj(f, out) + return "binary" + + +def main(): + ap = argparse.ArgumentParser(description="HLS 拉流下载器") + ap.add_argument("url", help="m3u8 地址(或用 --ts-url 下单个分片)") + ap.add_argument("-o", "--output", default="output.mp4", help="输出文件,默认 output.mp4") + ap.add_argument("--referer", default="", help="防盗链 Referer,如 https://huangguo.me/") + ap.add_argument("--proxy", default="none", + help="HTTP 代理,默认直连;需要时传如 http://127.0.0.1:7890") + ap.add_argument("--workers", type=int, default=8, help="并发下载数,默认 8") + ap.add_argument("--ts-url", default="", help="直接下载单个 .ts 分片") + args = ap.parse_args() + proxy = None if args.proxy.lower() == "none" else args.proxy + + # ---- 单分片模式 ---- + if args.ts_url: + data = http_get(args.ts_url, args.referer, proxy) + with open(args.output, "wb") as f: + f.write(data) + print(f"[OK] 分片已保存: {args.output} ({len(data)} 字节)") + return + + # ---- 拉取 m3u8,层层解析 ---- + url, text, depth = args.url, None, 0 + while True: + depth += 1 + if depth > 5: + sys.exit("嵌套层级过深,疑似异常") + print(f"[*] 拉取播放列表: {url}") + raw = http_get(url, args.referer, proxy) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + text = raw.decode("latin-1") + kind, items = parse_m3u8(text, url) + if kind == "master": + items.sort(key=lambda v: v[0], reverse=True) + print(f"[+] {len(items)} 个码率,选最高 {items[0][0]} bps") + url = items[0][1] + continue + break + + segments = items + if not segments: + sys.exit("播放列表里没有分片") + print(f"[+] 共 {len(segments)} 个分片") + if any(s.get("key") for s in segments): + print("[!] 流是 AES-128 加密的,将尝试解密(需 pycryptodome,否则请改用 ffmpeg)") + + # ---- 并发下载 ---- + tmpdir = tempfile.mkdtemp(prefix="hls_") + files, done = [None] * len(segments), 0 + + def task(i_seg): + i, seg = i_seg + suffix = ".mp4" if seg.get("init") else ".ts" + path = os.path.join(tmpdir, f"{i:05d}{suffix}") + if not os.path.exists(path): + with open(path, "wb") as f: + f.write(download_segment(seg, args.referer, proxy)) + return i, path + + with ThreadPoolExecutor(max_workers=args.workers) as ex: + futures = [ex.submit(task, (i, s)) for i, s in enumerate(segments)] + for fut in as_completed(futures): + i, path = fut.result() + files[i] = path + done += 1 + print(f"\r[*] 进度 {done}/{len(segments)}", end="", flush=True) + print() + + # ---- 合并 ---- + mode = merge(files, args.output) + size = os.path.getsize(args.output) + print(f"[OK] 合并方式: {'ffmpeg (无损)' if mode == 'ffmpeg' else '二进制拼接'}") + print(f"[OK] 已保存: {os.path.abspath(args.output)} ({size / 1024 / 1024:.2f} MB)") + # 清理 + for p in files: + try: + os.remove(p) + except OSError: + pass + os.rmdir(tmpdir) + + +if __name__ == "__main__": + main() diff --git a/huangguo_video_dl.user.js b/huangguo_video_dl.user.js new file mode 100644 index 0000000..d70ac1a --- /dev/null +++ b/huangguo_video_dl.user.js @@ -0,0 +1,361 @@ +// ==UserScript== +// @name 黄果短剧全集下载 +// @namespace huangguo-video +// @version 1.1.2 +// @description 在黄果短剧详情页添加"全集下载"按钮:下载全部集数 → 解密 → 转 MP4 → 打包 ZIP 存到 ~/Downloads,文件名为 剧名_集数 +// @author CETACEA +// @match https://huangguo.me/detail/* +// @connect huangguo.me +// @connect *.rlkfus.cn +// @connect *.cloudfront.net +// @connect * +// @require https://cdn.jsdelivr.net/npm/mux.js@6/dist/mux.min.js +// @grant GM_xmlhttpRequest +// @run-at document-idle +// ==/UserScript== + +(function () { + 'use strict'; + + const SITE = 'https://huangguo.me'; + const M3U8_BASE = SITE + '/h5/m3u8/'; // data-url 是相对路径,拼这个前缀(拼错 404) + const CONCURRENCY = 4; // 分片并发下载数 + + /* ---------- 网络:GM_xmlhttpRequest promise 化(绕 CORS) ---------- */ + function gfetch(url, type = 'text') { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'GET', + url, + responseType: type === 'arraybuffer' ? 'arraybuffer' : 'text', + timeout: 30000, + onload: (r) => (r.status >= 200 && r.status < 300 + ? resolve(type === 'arraybuffer' ? r.response : r.responseText) + : reject(new Error(`HTTP ${r.status}: ${url.slice(0, 100)}`))), + onerror: () => reject(new Error(`网络错误: ${url.slice(0, 100)}`)), + ontimeout: () => reject(new Error(`超时: ${url.slice(0, 100)}`)), + }); + }); + } + + /* ---------- 详情页:剧名 + 集数列表 ---------- */ + function collectInfo() { + const ctx = document.querySelector('[data-drama-context]') || document.body; + const title = (ctx.dataset.dramaTitle + || (document.querySelector('h1') || {}).textContent + || document.title.split(' - ')[0]).trim(); + const total = parseInt(ctx.dataset.totalEpisode || '0', 10); + + // 收集所有集;若 DOM 只渲染了当前 range,点 range 按钮补齐 + const harvest = () => [...document.querySelectorAll('a.detail-episode[data-ep]')].map((a) => ({ + ep: parseInt(a.dataset.ep, 10), + href: a.getAttribute('href'), + locked: a.hasAttribute('data-coin-dialog-open') || (a.getAttribute('href') || '').startsWith('javascript'), + chapterId: a.dataset.chapterId || null, + })); + let eps = harvest(); + if (total && eps.length < total) { + for (const btn of document.querySelectorAll('[data-range-start]')) { + if (btn.classList.contains('detail-episode-range--active')) continue; + btn.click(); + eps = harvest(); // 简化:以最后一次为准;长剧分页场景以实际 DOM 为准 + } + } + // 去重 + 排序 + const seen = new Map(); + for (const e of eps) seen.set(e.ep, e); + return { title, total, episodes: [...seen.values()].sort((a, b) => a.ep - b.ep) }; + } + + /* ---------- 单集:play 页 → m3u8 → 解密 → 转 MP4 ---------- */ + async function downloadEpisode(info, onStage) { + // 1. play 页拿 m3u8 相对地址 + onStage('获取播放页'); + const playUrl = info.href.startsWith('http') + ? info.href + : new URL(info.href, SITE).href; + const page = await gfetch(playUrl); + const m = page.match(/data-url="([^"]*m3u8[^"]*)"/); + if (!m) throw new Error('播放页无 m3u8(可能未解锁)'); + const m3u8url = new URL(m[1].replaceAll('&', '&'), M3U8_BASE).href; + + // 2. 解析播放列表 + onStage('解析列表'); + let pl = await gfetch(m3u8url); + if (pl.includes('#EXT-X-STREAM-INF')) { // master → 取第一个变体 + const varPath = pl.split('\n').find((l) => l && !l.startsWith('#')); + pl = await gfetch(new URL(varPath, m3u8url).href); + } + const segs = pl.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('http') || (l && !l.startsWith('#'))); + const segUrls = segs.map((s) => new URL(s, m3u8url).href); + if (!segUrls.length) throw new Error('播放列表为空'); + const keyMatch = pl.match(/#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"/); + const ivMatch = pl.match(/#EXT-X-KEY:[^,\n]*IV=(0x[0-9a-fA-F]+)/); + const mediaSeq = parseInt((pl.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/) || [0, 0])[1], 10); + + // 3. 解密 key(AES-128,16 字节) + let cryptoKey = null; + if (keyMatch) { + onStage('获取解密 key'); + const raw = await gfetch(new URL(keyMatch[1], m3u8url).href, 'arraybuffer'); + cryptoKey = await crypto.subtle.importKey('raw', raw, 'AES-CBC', false, ['decrypt']); + } + + // 4. 并发下载 + 解密 + const plain = new Array(segUrls.length); + let done = 0; + const fetchOne = async (i) => { + const ct = await gfetch(segUrls[i], 'arraybuffer'); + if (cryptoKey) { + const iv = new Uint8Array(16); + if (ivMatch) { // IV 显式声明 + const hex = ivMatch[1].slice(2).padStart(32, '0'); + for (let k = 0; k < 16; k++) iv[k] = parseInt(hex.slice(k * 2, k * 2 + 2), 16); + } else { // 无 IV:media sequence 大端填充 + new DataView(iv.buffer).setUint32(12, mediaSeq + i, false); + } + try { + plain[i] = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ct)); + } catch (e) { + throw new Error(`第 ${i + 1} 段解密失败(IV/key 不匹配): ${e.message}`); + } + } else { + plain[i] = new Uint8Array(ct); + } + done++; + onStage(`下载分片 ${done}/${segUrls.length}`); + }; + for (let i = 0; i < segUrls.length; i += CONCURRENCY) { + await Promise.all([...Array(CONCURRENCY).keys()] + .map((k) => i + k < segUrls.length ? fetchOne(i + k) : null) + .filter(Boolean)); + } + + // 5. 拼 TS → mux.js 转 MP4(faststart 无关,fMP4 通用可播) + onStage('转封装 MP4'); + const totalLen = plain.reduce((s, p) => s + p.length, 0); + const ts = new Uint8Array(totalLen); + let off = 0; + for (const p of plain) { ts.set(p, off); off += p.length; } + + // mux.js v6 浏览器 UMD 全局名是 muxjs(非 mux),两者都兼容 + const MuxNS = window.muxjs || window.mux; + if (!MuxNS || !MuxNS.mp4 || !MuxNS.mp4.Transmuxer) + throw new Error('mux.js 未加载(@require CDN 可能被墙),无法转封装'); + const tm = new MuxNS.mp4.Transmuxer({ outputContainer: 'mp4' }); + const chunks = []; + let initSeg = null; + tm.on('data', (seg) => { + if (seg.initSegment && !initSeg) initSeg = seg.initSegment; + if (seg.data && seg.data.length) chunks.push(seg.data); + }); + await new Promise((resolve, reject) => { + tm.on('error', reject); + tm.push(ts); + tm.flush(); + resolve(); + }); + if (!initSeg || !chunks.length) throw new Error('转封装无输出'); + const mp4 = new Uint8Array(initSeg.length + chunks.reduce((s, c) => s + c.length, 0)); + let o = 0; + for (const c of [initSeg, ...chunks]) { mp4.set(c, o); o += c.length; } + return mp4; + } + + /* ---------- 手写 ZIP(STORE 不压缩;Blob 拼接不吃 JS 堆) ---------- */ + const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; + })(); + + function crc32(u8) { + let c = 0xffffffff; + for (let i = 0; i < u8.length; i++) c = CRC_TABLE[(c ^ u8[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + } + + async function buildZip(entries, onStep) { + const enc = new TextEncoder(); + const now = new Date(); + const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; + const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; + const parts = [], central = []; + let offset = 0, cdSize = 0; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const name = enc.encode(e.name); + // 本地文件头 + const lh = new DataView(new ArrayBuffer(30)); + lh.setUint32(0, 0x04034b50, true); + lh.setUint16(4, 20, true); + lh.setUint16(6, 0x0800, true); // bit11:文件名 UTF-8 + lh.setUint16(8, 0, true); // STORE 不压缩 + lh.setUint16(10, dosTime, true); + lh.setUint16(12, dosDate, true); + lh.setUint32(14, e.crc, true); + lh.setUint32(18, e.size, true); + lh.setUint32(22, e.size, true); + lh.setUint16(26, name.length, true); + parts.push(lh.buffer, name, e.blob); + // 中央目录项 + const ch = new DataView(new ArrayBuffer(46)); + ch.setUint32(0, 0x02014b50, true); + ch.setUint16(4, 20, true); + ch.setUint16(6, 20, true); + ch.setUint16(8, 0x0800, true); + ch.setUint16(10, 0, true); + ch.setUint16(12, dosTime, true); + ch.setUint16(14, dosDate, true); + ch.setUint32(16, e.crc, true); + ch.setUint32(20, e.size, true); + ch.setUint32(24, e.size, true); + ch.setUint16(28, name.length, true); + ch.setUint32(42, offset, true); + central.push(ch.buffer, name); + offset += 30 + name.length + e.size; + cdSize += 46 + name.length; + if (onStep) onStep(i + 1, entries.length); + if ((i & 3) === 3) await new Promise((r) => setTimeout(r, 0)); // 让 UI 有机会刷新 + } + const eocd = new DataView(new ArrayBuffer(22)); + eocd.setUint32(0, 0x06054b50, true); + eocd.setUint16(8, entries.length, true); + eocd.setUint16(10, entries.length, true); + eocd.setUint32(12, cdSize, true); + eocd.setUint32(16, offset, true); + return new Blob([...parts, ...central, eocd.buffer], { type: 'application/zip' }); + } + + /* ---------- UI:右下角浮动面板 ---------- */ + const ui = (() => { + const box = document.createElement('div'); + box.style.cssText = `position:fixed;right:16px;bottom:16px;z-index:99999;width:300px; + background:#141824;color:#e8ecf5;border:1px solid #2c3550;border-radius:12px; + font:13px/1.6 -apple-system,sans-serif;padding:14px 16px;box-shadow:0 8px 32px rgba(0,0,0,.5);display:none`; + box.innerHTML = ` +
+ 全集下载 + ✕ +
+
+
+
+
+
0%
`; + document.body.appendChild(box); + box.querySelector('#whale-close').onclick = () => (box.style.display = 'none'); + const $ = (id) => box.querySelector('#whale-' + id); + return { + show(t) { $('title').textContent = t; box.style.display = 'block'; }, + stage(s) { $('stage').textContent = s; }, + prog(p) { $('bar').style.width = p + '%'; $('pct').textContent = Math.round(p) + '%'; }, + hide() { box.style.display = 'none'; }, + }; + })(); + + /* ---------- 主流程 ---------- */ + async function run() { + const { title, total, episodes } = collectInfo(); + const free = episodes.filter((e) => !e.locked); + const locked = episodes.filter((e) => e.locked); + if (!free.length) return alert('没有可下载的免费集数'); + + const safe = title.replace(/[\\/:*?"<>|]/g, '_'); + ui.show(`《${title}》全集下载`); + const entries = []; // {name, size, crc, blob} —— 逐集转 Blob 卸到堆外,绕开 JSZip 大文件卡死 + const failList = []; + let firstErr = ''; + + for (let n = 0; n < free.length; n++) { + const e = free[n]; + const base = ((n) / free.length) * 100; + try { + const mp4 = await downloadEpisode(e, (s) => ui.stage(`[${n + 1}/${free.length}] ${s}`)); + entries.push({ name: `${safe}_${e.ep}.mp4`, size: mp4.length, crc: crc32(mp4), blob: new Blob([mp4], { type: 'video/mp4' }) }); + ui.stage(`✅ 第 ${e.ep} 集完成 (${(mp4.length / 1048576).toFixed(1)} MB)`); + } catch (err) { + const emsg = (err && err.message) ? err.message : String(err); + console.error(`[全集下载] 第${e.ep}集失败:`, err); + failList.push(e.ep); + if (!firstErr) firstErr = `第${e.ep}集: ${emsg}`; + ui.stage(`❌ 第 ${e.ep} 集失败: ${emsg}`); + } + ui.prog(((n + 1) / free.length) * 85); // 打包占最后 15% + } + + // 手写 STORE 打包 + 触发下载(浏览器默认下载目录 = ~/Downloads) + // JSZip generateAsync 处理多集大文件会永久卡在 90%,已弃用 + if (!entries.length) { + ui.stage(`💥 全部失败!${firstErr || '未知原因'}(F12 控制台有完整错误)`); + console.error('[全集下载] 全部集数失败,首个错误:', firstErr); + return; + } + let savedAsZip = true; + let blob = null; + try { + blob = await buildZip(entries, (i, tot) => { + ui.stage(`打包 ZIP 中… ${i}/${tot}`); + ui.prog(85 + (i / tot) * 14); + }); + } catch (zipErr) { + // 兜底:内存不足等导致 ZIP 组装失败 → 逐集保存 + console.error('[全集下载] ZIP 组装失败,改为逐集保存:', zipErr); + savedAsZip = false; + for (const ent of entries) { + ui.stage(`逐集保存 ${ent.name} …`); + const a2 = document.createElement('a'); + a2.href = URL.createObjectURL(ent.blob); + a2.download = ent.name; + document.body.appendChild(a2); + a2.click(); + a2.remove(); + await new Promise((r) => setTimeout(r, 800)); + } + } + if (savedAsZip) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${safe}.zip`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 120000); + } + + let msg = savedAsZip ? `完成!${safe}.zip → ~/Downloads` : `完成!已逐集保存 ${entries.length} 个 MP4 → ~/Downloads`; + if (locked.length) msg += ` | 跳过付费集:${locked.map((e) => '第' + e.ep + '集').join('、')}`; + if (failList.length) msg += ` | 失败:${failList.join('、')}(${firstErr})`; + ui.stage(msg); + ui.prog(100); + console.log(`[全集下载] ${msg}`); + } + + /* ---------- 注入按钮(详情页剧集区) ---------- */ + function inject() { + if (document.getElementById('whale-dl-btn')) return; + const anchor = document.querySelector('.detail-episodes-status') + || document.querySelector('.detail-section-label') + || document.querySelector('h1'); + if (!anchor) return; + const btn = document.createElement('button'); + btn.id = 'whale-dl-btn'; + btn.textContent = '⬇ 全集下载'; + btn.style.cssText = `margin-left:12px;padding:6px 14px;border:0;border-radius:8px;cursor:pointer; + background:linear-gradient(135deg,#3b82f6,#22d3ee);color:#fff;font:600 13px -apple-system,sans-serif; + vertical-align:middle`; + btn.onclick = () => run().catch((e) => { + console.error('[全集下载]', e); + ui.stage('💥 出错:' + e.message); + }); + anchor.insertAdjacentElement('afterend', btn); + } + + inject(); + // SPA/异步渲染兜底 + new MutationObserver(inject).observe(document.body, { childList: true, subtree: true }); +})(); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..104bf39 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "huangguo-video" +version = "0.1.0" +description = "HLS 拉流下载器(抓包视频下载)" +requires-python = ">=3.9" +dependencies = [ + "pycryptodome>=3.20", +] + +[tool.uv] +# 本机走了 Reqable/mihomo MITM 代理,uv 自带证书库不认系统 CA,必须开系统证书 +system-certs = true diff --git a/reqable_mcp.py b/reqable_mcp.py new file mode 100644 index 0000000..2fc2e51 --- /dev/null +++ b/reqable_mcp.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Reqable MCP 客户端(stdio 传输) +用法: + python3 reqable_mcp.py list # 列出所有工具 + python3 reqable_mcp.py call '' # 调用工具,input 为 JSON + python3 reqable_mcp.py m3u8 # 快捷:搜出所有 m3u8 请求 +""" + +import json +import subprocess +import sys + +MCP_BIN = "/Applications/Reqable.app/Contents/Helpers/mcp-server" +# Reqable 实际监听端口(adb 占了 9000,实际用 9002) +REQABLE_PORT = 9002 + + +class McpClient: + def __init__(self): + self.proc = subprocess.Popen( + [MCP_BIN, "--port", str(REQABLE_PORT)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + ) + self._id = 0 + + def _send(self, obj): + self.proc.stdin.write(json.dumps(obj) + "\n") + self.proc.stdin.flush() + + def _recv(self, expect_id): + """读到匹配 id 的 response 为止,跳过 notification""" + while True: + line = self.proc.stdout.readline() + if not line: + raise RuntimeError("mcp-server 关闭了管道") + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("id") == expect_id: + if "error" in msg: + raise RuntimeError(f"MCP error: {msg['error']}") + return msg.get("result") + + def request(self, method, params=None): + self._id += 1 + self._send({"jsonrpc": "2.0", "id": self._id, + "method": method, "params": params or {}}) + return self._recv(self._id) + + def initialize(self): + r = self.request("initialize", { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "hls-whale", "version": "0.1"}, + }) + self._send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + return r + + def list_tools(self): + return self.request("tools/list")["tools"] + + def call(self, name, arguments): + return self.request("tools/call", {"name": name, "arguments": arguments}) + + def close(self): + try: + self.proc.stdin.close() + self.proc.wait(timeout=3) + except Exception: + self.proc.kill() + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + cmd = sys.argv[1] + c = McpClient() + info = c.initialize() + print(f"[MCP] {info['serverInfo']['name']} {info['serverInfo'].get('version','')}", + file=sys.stderr) + + try: + if cmd == "list": + for t in c.list_tools(): + desc = (t.get("description") or "").split("\n")[0][:80] + print(f"{t['name']:40s} {desc}") + elif cmd == "call": + name = sys.argv[2] + args = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {} + r = c.call(name, args) + for block in r.get("content", []): + print(block.get("text", "")) + if r.get("isError"): + sys.exit(2) + elif cmd == "m3u8": + # 快捷:找出搜索类工具并搜 m3u8 + tools = {t["name"]: t for t in c.list_tools()} + search = next((n for n in tools if "search" in n or "filter" in n or "find" in n), None) + if not search: + print("没找到搜索工具,工具列表:", list(tools), file=sys.stderr) + sys.exit(1) + print(f"[用 {search}] 搜 m3u8", file=sys.stderr) + r = c.call(search, {"keyword": "m3u8"} if "keyword" in json.dumps(tools[search]) else {"query": "m3u8"}) + for block in r.get("content", []): + print(block.get("text", "")) + else: + print(__doc__) + finally: + c.close() + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6bc0e46 --- /dev/null +++ b/uv.lock @@ -0,0 +1,57 @@ +version = 1 +revision = 5 +requires-python = ">=3.9" + +[[package]] +name = "huangguo-video" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pycryptodome" }, +] + +[package.metadata] +requires-dist = [{ name = "pycryptodome", specifier = ">=3.20" }] + +[[package]] +name = "pycryptodome" +version = "3.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/e0/0d0bd5b1089a4bf5ef48164459289ddf02a9110ca1db854edaad25127e64/pycryptodome-3.24.0.tar.gz", hash = "sha256:9140779b40405476a799305b9ac1bcaab4ee6791dc3d38b12a9aa84ffbd6aabf", size = 4930687, upload-time = "2026-10-04T17:36:28.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/a5/1ced96d8dc523610227aaeb36c6d92bffd6e4e75dd7bce28ff13cfb617cb/pycryptodome-3.24.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4c56912453dda840f2efb3a18e175607e2827a635f4433fd1b49777c648fa885", size = 2472257, upload-time = "2026-10-04T17:34:40.745Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/5eda20177cac3937916dc49659248bee93e858cb276a420d2a34b958241c/pycryptodome-3.24.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a8b459b0f5b874bf657ef6f7d5c83e5cda9ca6e9d0e578dd798dce60c756277e", size = 1639865, upload-time = "2026-10-04T17:34:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a8/0bec75ba00675ebb95e1b62498efe3ed45de0e379fa0cef92298c412c2e3/pycryptodome-3.24.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d4f5e21bc638c09a5d1e1201c61cb4bbcef7ad7756f103deb9dd5f941d385b", size = 2191121, upload-time = "2026-10-04T17:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/3eb4b3d81f9feff1bed8aae399ad6a00ef3ec05779dddb951728a614341a/pycryptodome-3.24.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e8b9090197bca609a07ef9226ca2b8de99fed5ecd521d5c35d5cb9e6db861c8c", size = 2276969, upload-time = "2026-10-04T17:34:49.7Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d7/65fe8d490a1c4ba708b6d3ac667affc3d5510155ade1bbfb00a80240d681/pycryptodome-3.24.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f265dddd46892f77a63b3878b9193c2f8da7a3930105e885eb2062d845704f2", size = 2182665, upload-time = "2026-10-04T17:34:52.721Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a1/05d5cd6ecb31c26e3b9511944e0a58a39cd28da3defa7968a7aefe1ac213/pycryptodome-3.24.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68a6e52e2efeea81c840ddf44f985cd58351cf97b1b954dea70cb6a950368837", size = 2274729, upload-time = "2026-10-04T17:34:55.381Z" }, + { url = "https://files.pythonhosted.org/packages/5b/bc/e30677a0092fd1cdb24d18bf9d3a6c15b73945f71627629aa121f04caa7c/pycryptodome-3.24.0-cp313-cp313t-win32.whl", hash = "sha256:988ba7d2374ea7ac0318a4b2345bb52daee36ca386eec703101b9c38dce7950a", size = 1789276, upload-time = "2026-10-04T17:34:57.596Z" }, + { url = "https://files.pythonhosted.org/packages/c0/48/6f51459c6f80a71375e1dce8eaebc89dcb4e357cb83ae613a126430cf68f/pycryptodome-3.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4839a0d796755e2e9c85e890a80f4077c18a661eb4aba2ba8bd0c3fe9f23887f", size = 1822149, upload-time = "2026-10-04T17:35:00.501Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/88ad4d49a57c5767254661c6311ce4f3c22cf39e6c8e98b2433aa1aea408/pycryptodome-3.24.0-cp313-cp313t-win_arm64.whl", hash = "sha256:df855e0a99ac7e223a4e4e620a32daaa5aa48c0ce9b4b4333bebe562efd99ebf", size = 1755938, upload-time = "2026-10-04T17:35:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/61/ac/b247f586e7489e8227711f7ac9495663ea1a79fe2795ab712b32a978c0fb/pycryptodome-3.24.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4ded554286a961b262a576c7417abf70d2f9017ce9c87f5eba200e696ae48f49", size = 2473198, upload-time = "2026-10-04T17:35:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c6/62b9fb63a004cae09181ed0b99d95919e73f6c502ed4155942a31220c1f9/pycryptodome-3.24.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e420b2b36877272db44e211cc278e6c80d4a1c05e0440863e00e42ebe0a270b", size = 1642023, upload-time = "2026-10-04T17:35:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/51/72/7366f9d66ab324be3d598d4d9d36ae15907120c8ff490fb760359f2b8588/pycryptodome-3.24.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:016a309085a2bce464ca622f5d115a877a4da7cecf35caeeebfadc1573ce5c8c", size = 2191121, upload-time = "2026-10-04T17:35:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/0924b0594aae0178c6d6991dfe5be9fcedde881f41933d7a9b7a60ae09eb/pycryptodome-3.24.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:04abdcc32afbed3e9a64615793d09d95929491c2f3bd7d9beb23a8702a118277", size = 2276971, upload-time = "2026-10-04T17:35:14.867Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2c/65aa8c82bd8eb49b070aa18a1a5f76905b1da2c5e3fc9b9b0e59796b3588/pycryptodome-3.24.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff59a473afa6dbde1a3566569d68d8ce55e43cb7d3fc2e868cc4a7a08c2e8469", size = 2182665, upload-time = "2026-10-04T17:35:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0f/c54c59cc67384742e6580f1f68c2ded9a4e0eb554dd6d68e4f70ae112ed5/pycryptodome-3.24.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:26c06f99ed10ba12e8b9fb69b466eaab9b7a1f01d1279f8b4a93c067c0979cfa", size = 2274729, upload-time = "2026-10-04T17:35:21.184Z" }, + { url = "https://files.pythonhosted.org/packages/2f/58/9cf73f22480657151d1bef294032688f7def06f152efcb2c7f74ee0291c0/pycryptodome-3.24.0-cp314-cp314t-win32.whl", hash = "sha256:0c06fa466de3d274c44734ef7280f1048c0726bf0b21071257632fd0ce5f628f", size = 1805288, upload-time = "2026-10-04T17:35:24.048Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bb/d2e9edc9ba747989d6db221159e5db68a456690ae665ab7e8cd353b9c08c/pycryptodome-3.24.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ab093fcae708a43aa28170c10084ecd48aee9509ca6dfcce266a3da7d282c217", size = 1839081, upload-time = "2026-10-04T17:35:27.336Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2c/a7518171e05a03d6b16665c3002dec85109d208c8906f161f072490efc0b/pycryptodome-3.24.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8f65c105867799f5b49b85d092247bdb645a564d3a050b4e9a39420afa931489", size = 1774469, upload-time = "2026-10-04T17:35:30.393Z" }, + { url = "https://files.pythonhosted.org/packages/03/3e/7a3b9bfc5d600bd89a832f25ab0fbe1bd5eab84003cdf436bc0b91f3f932/pycryptodome-3.24.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:a6bfd33b3cea155446aabe682f61c6c7518581df7a97546327b824c3f8309005", size = 2473100, upload-time = "2026-10-04T17:35:33.098Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/10ae42ab01edbfcbe74f929aef45c86bea876d568b8f9da4e3c8b5c85566/pycryptodome-3.24.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:118b2be7dd82b639492623a6b2bda545fbb470eed9fa1c31ccd56340aa6cc9a6", size = 1640720, upload-time = "2026-10-04T17:35:36.155Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/128bbb9b00e2da47d2f6bed68c13dfea466f1116e2f330a401634271978e/pycryptodome-3.24.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:585b8eaffb7acb1db161de9c7579687ea6dae493663392fc4016a0e329526c56", size = 2189593, upload-time = "2026-10-04T17:35:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/1a7c58f5b839fbf65965461b554bb1297839fdb8880b5ab92c8331b7a02a/pycryptodome-3.24.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf975cc3a0822a662ec2cdae85b38ad6f67654f9b48fbe02c5baae5999a6c18d", size = 2275758, upload-time = "2026-10-04T17:35:42.103Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/48b8e52c3c99447a487a0bd0933d8c12fbd63b4465936fb06db96b9714ba/pycryptodome-3.24.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0b26310cfa9ca1b8504f316fe0c534e5be614f2c5147de3ae7431ce51f5a7245", size = 2181147, upload-time = "2026-10-04T17:35:45.63Z" }, + { url = "https://files.pythonhosted.org/packages/2d/99/1366254ca526149bad624165deb84aff069a9232164173b1b7abb28b91b9/pycryptodome-3.24.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:64b2f24507d38ba489a89d7b41b1a31ffe468fccfb9bea5c94f7e04a2aca93d6", size = 2273277, upload-time = "2026-10-04T17:35:48.77Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fb/f19e1e34d86dbdb0c8cf52b317d02e3488ef129deda3d83a95db7fcb1b57/pycryptodome-3.24.0-cp37-abi3-win32.whl", hash = "sha256:b5c5fecc6232d71ea66a2d40db6b4169302f6f9f4803903809db1e2869977905", size = 1789157, upload-time = "2026-10-04T17:35:51.597Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/4cd7b5b7f3e4c7012cbffc2295af8982b11c9f649079d90a90525200b5c4/pycryptodome-3.24.0-cp37-abi3-win_amd64.whl", hash = "sha256:89a9c14b18f43491d7bec4440c7179eb51a56891c3070e41ae726cb3734c6b9b", size = 1822033, upload-time = "2026-10-04T17:35:54.364Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/d5a2f6fa5a39634fecc8ac79d321ae774172099b86392af6380671af1ce0/pycryptodome-3.24.0-cp37-abi3-win_arm64.whl", hash = "sha256:e6870f15ecbc61c25058bc5d163189af5c81a2ac42574bac4f2e927720b89c34", size = 1755935, upload-time = "2026-10-04T17:35:57.358Z" }, + { url = "https://files.pythonhosted.org/packages/1f/73/f9f337db60b5a4dc62fcc34360e131cbae894fe3abea2fcc3559c7a5a091/pycryptodome-3.24.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:27cb8fbe6ba84ba508b3e94ef22e3fd496d91be2e5f624fa8f827ff23d1f455c", size = 1622312, upload-time = "2026-10-04T17:36:05.459Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/1855014371c957579ab603986bd5b183b6189267d66a2d3757d6c04c4bd9/pycryptodome-3.24.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:98d4efe5ee7fba703591b82b5c2c3f5f3b82a61c7807db4c7be2ab5ece07729c", size = 1677979, upload-time = "2026-10-04T17:36:08.434Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/a03c73a9e4a9cb3ed5baefc5e0639109ac8ec6800e95d79645c252e5412c/pycryptodome-3.24.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f83fb5a5c95a888d0ee3542890a113b98fd871e45758902845721894c99ea028", size = 1670228, upload-time = "2026-10-04T17:36:10.849Z" }, + { url = "https://files.pythonhosted.org/packages/89/5f/f8e7c8e802700554d73ef4b3c16c79985ca98f43621819e16cfe74ccd513/pycryptodome-3.24.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e00162d4ae4c68d533294103ea3a60dee9c89d75a1281e0e4e3e064986cc7388", size = 1824973, upload-time = "2026-10-04T17:36:13.64Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/614c4ab80ff8bcbb31777083ac7f09680a24b1915061e60b5631ed6c81c8/pycryptodome-3.24.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:891a1eb7a31c6dd23b00d614ad9b5e978d17969cfab2abb8c974e83006ff876f", size = 1618674, upload-time = "2026-10-04T17:36:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b7/81cd3754cfada6abdaa42d7868ae3106d8711101c1642f854b067b2b2538/pycryptodome-3.24.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9d8c5a84de826bda6190d37d3a9f7ad404ea16c32718a5935a6d7cd1adbc6e6", size = 1677980, upload-time = "2026-10-04T17:36:19.241Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/eed7951535cc325f75661d387407e16c802419bad0aa957e696524bada27/pycryptodome-3.24.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9eacc321c920184b09558f1f0a0bbcf32849b94720c57b5b0d04b88ea7573f81", size = 1670228, upload-time = "2026-10-04T17:36:21.722Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/7f48a5e29d11020a0593404f5cfa25701e5cd451562018a796216acaefea/pycryptodome-3.24.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ce37669ec6a71d76defc5403bfc3cebd78949ce269f63fc305c0c5c1900b5e1a", size = 1824989, upload-time = "2026-10-04T17:36:24.468Z" }, +]