初始提交:黄果短剧抓包下载工具集

- 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: 用法与踩坑记录
This commit is contained in:
2026-10-08 17:19:00 +08:00
commit eaacacc8e0
7 changed files with 920 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
.DS_Store
*.mp4
*.zip
/tmp/
+117
View File
@@ -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/<slug>/`:
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 秒完成。
+245
View File
@@ -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()
+361
View File
@@ -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('&amp;', '&'), 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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<b id="whale-title">全集下载</b>
<span id="whale-close" style="cursor:pointer;opacity:.6">✕</span>
</div>
<div id="whale-stage" style="color:#8fa3c8;min-height:20px"></div>
<div style="height:6px;background:#232b40;border-radius:3px;margin:8px 0;overflow:hidden">
<div id="whale-bar" style="height:100%;width:0;background:linear-gradient(90deg,#3b82f6,#22d3ee);transition:width .3s"></div>
</div>
<div id="whale-pct" style="color:#5d6b8c;font-size:12px">0%</div>`;
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 });
})();
+12
View File
@@ -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
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Reqable MCP 客户端(stdio 传输)
用法:
python3 reqable_mcp.py list # 列出所有工具
python3 reqable_mcp.py call <tool> '<json>' # 调用工具,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()
Generated
+57
View File
@@ -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" },
]