初始提交:黄果短剧抓包下载工具集
- 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:
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
*.mp4
|
||||
*.zip
|
||||
/tmp/
|
||||
@@ -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 秒完成。
|
||||
@@ -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()
|
||||
@@ -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 = `
|
||||
<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 });
|
||||
})();
|
||||
@@ -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
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user