小鹅通 M3U8 视频下载器

实用软件7天前更新 小编
4 0

支持自动解密、多线程并发与浏览器联动的本地命令行下载工具,配合浏览器,能够无缝接管课程下载任务,真正进入高效的“边看边存”状态

核心特性
浏览器联动,一键发送:运行后在本地启动 HTTP 服务(端口 8910),配合浏览器脚本,在课程页面点击“发送到下载”即可自动拉起后台下载,零繁琐链接复制,彻底解放双手。
开箱即用,免配依赖:脚本内置环境自检逻辑。首次运行自动检测并安装缺失的 Python 依赖(requests, pycryptodome, tqdm)。
全自动解密与合并,效率翻倍:
        自动解密 → 自动获取并解析 AES-128 密钥与 IV,在内存中完成 TS 切片解密。
智能合并 → 极速调用 FFmpeg 将切片合并为 MP4,内置“Stream Copy”与“重编码”双重回退机制,确保视频完美合并。
多线程并发,极速下载:采用 ThreadPoolExecutor 提供多线程并发下载,搭配 tqdm 进度条,直观展示下载进度与切片完成情况。
稳定可靠,日志追溯:提供“控制台+本地日志(downloader.log)”双重记录,所有下载历史有迹可循。

重点说明:
// @match        *://*.xiaoeknow.com/*
// @match        *://*.xet.tech/*
// @match        *://*.xiaoe-tech.com/*
// @match        *://*.xet.pomoho.com/*
// @match        *://*.xet-pc.citv.cn/*
因平台支持自定义域名,因此若脚本中不包含所购买的课程的自定义域名,则需要手动进行添加

使用步骤:一、环境与工具准备安装扩展:为实现网页端联动,用户需要手动在浏览器(如 Chrome、Edge)的扩展商店中安装 Tampermonkey(油猴) 插件。添加脚本:在油猴插件中,添加并启用文件目录下的网页端用户脚本(用于在页面生成交互按钮)
小鹅通 M3U8 视频下载器

  • 免配置环境:程序已设置好本地 Winpython32-3.8.3.0 环境,无需手动创建环境,无需任何编译,真正做到开箱即用。
  • 启动服务:直接运行工具,程序会自动完成自检。当控制台出现“等待浏览器发送下载任务…”提示时,代表本地监听服务已成功启动。

二、一键发送任务,全自动下载
保持下载器的命令行窗口在后台运行,切勿关闭。
打开课程到播放页面,如需特定清晰度需要手动调整,等获取到视频地址后,直接点击由油猴脚本生成的“发送到下载”按钮。
接收到任务后,下载器将在本地 downloads 文件夹下全自动完成切片下载、密钥解密与最终的 MP4 视频合并导出。
小鹅通 M3U8 视频下载器
小鹅通 M3U8 视频下载器

最低系统可支持到win7 32位,若存在不兼容问题可以直接替换环境
[Python]

  1. """小鹅通 M3U8 视频下载器 — 交互式命令行版"""
  2. import os, time, shutil, subprocess, binascii, re
  3. from concurrent.futures import ThreadPoolExecutor, as_completed
  4. from urllib.parse import urljoin
  5. import requests
  6. from Crypto.Cipher import AES
  7. from Crypto.Util.Padding import unpad
  8. from tqdm import tqdm
  9. # 强制 stdout 行缓冲,确保 Windows CMD 中实时输出
  10. if hasattr(sys.stdout, 'reconfigure'):
  11.     sys.stdout.reconfigure(line_buffering=True)
  12. BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  13. OUT = os.path.join(BASE_DIR, 'downloads')
  14. os.makedirs(OUT, exist_ok=True)
  15. LOG_FILE = os.path.join(BASE_DIR, 'downloader.log')
  16. def _console_encoding():
  17.     """获取 Windows 控制台实际代码页,避免 UTF-8/GBK 猜错"""
  18.     try:
  19.         import ctypes
  20.         cp = ctypes.windll.kernel32.GetConsoleOutputCP()
  21.         if cp == 65001:
  22.             return 'utf-8'
  23.         elif cp == 936:
  24.             return 'gbk'
  25.         else:
  26.             return f'cp{cp}'
  27.     except Exception:
  28.         return (sys.stdout.encoding or 'utf-8').lower()
  29. CONSOLE_ENC = _console_encoding()
  30. def log(msg, end='\n'):
  31.     """同时输出到控制台和日志文件。控制台按实际代码页编码,避免乱码"""
  32.     ts = time.strftime('%H:%M:%S')
  33.     line = f'[{ts}] {msg}'
  34.     # 1. 文件日志(始终可靠)
  35.     try:
  36.         with open(LOG_FILE, 'a', encoding='utf-8') as f:
  37.             f.write(line)
  38.             if end:
  39.                 f.write(end)
  40.             f.flush()
  41.     except Exception:
  42.         pass
  43.     # 2. 控制台:按真实代码页编码
  44.     try:
  45.         data = (line + end).encode(CONSOLE_ENC, errors='replace')
  46.     except Exception:
  47.         data = (line + end).encode('utf-8', errors='replace')
  48.     try:
  49.         os.write(1, data)
  50.     except Exception:
  51.         try:
  52.             os.write(2, data)
  53.         except Exception:
  54.             pass
  55. FFMPEG = os.path.join(BASE_DIR, 'ffmpeg.exe')
  56. def safe_name(name):
  57.     """去除文件名中的非法字符"""
  58.     return re.sub(r'[<>:"/\\|?*]', '_', name).strip()
  59. def find_ffmpeg():
  60.     for p in [FFMPEG, shutil.which('ffmpeg'), shutil.which('ffmpeg.exe')]:
  61.         if p and os.path.isfile(p):
  62.             return p
  63.     return None
  64. def download_one(name, m3u8_url):
  65.     """下载单个视频"""
  66.     s = requests.Session()
  67.     s.headers.update({
  68.         'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
  69.         'Referer': 'https://xiaoe-tech.com/',
  70.     })
  71.     log(f'\n  {name}')
  72.     log(f'  {"─" * 50}')
  73.     # 1. 下载 M3U8
  74.     log('  [1/4] 下载索引...', end=' ')
  75.     try:
  76.         resp = s.get(m3u8_url, timeout=30)
  77.         resp.raise_for_status()
  78.     except Exception as e:
  79.         log(f'失败: {e}')
  80.         return False
  81.     m3u8 = resp.text
  82.     base = '/'.join(m3u8_url.split('/')[:-1]) + '/'
  83.     # 2. 解析
  84.     segments = []
  85.     key_url = None
  86.     iv = b'\x00' * 16
  87.     for line in m3u8.split('\n'):
  88.         line = line.strip()
  89.         if 'URI=' in line and 'AES-128' in line:
  90.             a = line.find('URI="') + 5
  91.             b = line.find('"', a)
  92.             key_url = line[a:b]
  93.             if not key_url.startswith('http'):
  94.                 key_url = urljoin(base, key_url)
  95.             iv_s = line.find('IV=0x')
  96.             if iv_s != -1:
  97.                 iv = binascii.unhexlify(line[iv_s + 5:iv_s + 37])
  98.         elif line and not line.startswith('#'):
  99.             u = line if line.startswith('http') else urljoin(base, line)
  100.             segments.append(u)
  101.     log(f'{len(segments)} 片段')
  102.     # 3. 获取密钥
  103.     log('  [2/4] 获取密钥...', end=' ')
  104.     try:
  105.         key = s.get(key_url, timeout=15).content
  106.         log(f'{len(key)} 字节')
  107.     except Exception as e:
  108.         log(f'失败: {e}')
  109.         return False
  110.     # 4. 并行下载
  111.     tmp = os.path.join(OUT, f'tmp_{int(time.time())}')
  112.     os.makedirs(tmp, exist_ok=True)
  113.     def dl_one(url, key, iv, idx):
  114.         for _ in range(3):
  115.             try:
  116.                 data = s.get(url, timeout=60).content
  117.                 break
  118.             except:
  119.                 time.sleep(1)
  120.         else:
  121.             return None
  122.         seg_iv = iv[:12] + idx.to_bytes(4, 'big')
  123.         dec = AES.new(key, AES.MODE_CBC, iv=seg_iv).decrypt(data)
  124.         try:
  125.             dec = unpad(dec, AES.block_size)
  126.         except:
  127.             pass
  128.         fp = os.path.join(tmp, f's_{idx:05d}.ts')
  129.         with open(fp, 'wb') as f:
  130.             f.write(dec)
  131.         return fp
  132.     t0 = time.time()
  133.     results = {}
  134.     with ThreadPoolExecutor(max_workers=6) as ex:
  135.         fut = {ex.submit(dl_one, u, key, iv, i): i for i, u in enumerate(segments)}
  136.         with tqdm(total=len(segments), desc='  [3/4] 下载中', unit='片',
  137.                   ncols=60, bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt}') as pbar:
  138.             for f in as_completed(fut):
  139.                 if f.result():
  140.                     results[fut[f]] = f.result()
  141.                     pbar.update(1)
  142.     elapsed = time.time() - t0
  143.     if not results:
  144.         log('  所有片段下载失败')
  145.         shutil.rmtree(tmp, ignore_errors=True)
  146.         return False
  147.     # 5. 合并
  148.     log(f'  [4/4] 合并 ({elapsed:.1f}s 下载, {len(results)}/{len(segments)} 成功)...', end=' ')
  149.     files = [results[i] for i in sorted(results)]
  150.     output = os.path.join(OUT, f'{safe_name(name)}.mp4')
  151.     # Concat 列表
  152.     lst = os.path.join(OUT, '_concat.txt')
  153.     with open(lst, 'w', encoding='utf-8') as f:
  154.         for fp in files:
  155.             f.write(f"file '{fp.replace(os.sep, '/')}'\n")
  156.     if os.path.exists(output):
  157.         os.remove(output)
  158.     cmd = [find_ffmpeg() or 'ffmpeg', '-f', 'concat', '-safe', '0',
  159.            '-i', lst, '-c', 'copy', '-movflags', '+faststart', '-y', output]
  160.     r = subprocess.run(cmd, capture_output=True, encoding='utf-8',
  161.                        errors='replace', timeout=600)
  162.     if os.path.exists(lst):
  163.         os.remove(lst)
  164.     if r.returncode != 0:
  165.         # 重编码回退
  166.         lst2 = os.path.join(OUT, '_concat2.txt')
  167.         with open(lst2, 'w', encoding='utf-8') as f:
  168.             for fp in files:
  169.                 f.write(f"file '{fp.replace(os.sep, '/')}'\n")
  170.         cmd2 = [find_ffmpeg() or 'ffmpeg', '-f', 'concat', '-safe', '0',
  171.                 '-i', lst2, '-c:v', 'libx264', '-c:a', 'aac',
  172.                 '-movflags', '+faststart', '-y', output]
  173.         subprocess.run(cmd2, capture_output=True, encoding='utf-8',
  174.                        errors='replace', timeout=600)
  175.         if os.path.exists(lst2):
  176.             os.remove(lst2)
  177.     shutil.rmtree(tmp, ignore_errors=True)
  178.     if os.path.exists(output):
  179.         mb = os.path.getsize(output) / 1048576
  180.         log(f'  {mb:.1f} MB  ->  {output}')
  181.         return True
  182.     else:
  183.         log('  合并失败')
  184.         return False
  185. # ============================================================
  186. #  HTTP 服务 — 接收浏览器发来的下载任务
  187. # ============================================================
  188. import json
  189. import traceback
  190. import threading
  191. from http.server import HTTPServer, BaseHTTPRequestHandler
  192. PORT = 8910
  193. total_count = 0
  194. count_lock = threading.Lock()
  195. # 线程池:支持同时下载最多3个
  196. download_pool = ThreadPoolExecutor(max_workers=3)
  197. class Handler(BaseHTTPRequestHandler):
  198.     def log_message(self, *args):
  199.         pass
  200.     def do_OPTIONS(self):
  201.         log(f'HTTP OPTIONS {self.path}')
  202.         self._cors()
  203.     def do_GET(self):
  204.         log(f'HTTP GET {self.path}')
  205.         self._cors()
  206.         self._json({'status': 'running', 'port': PORT, 'total': total_count})
  207.     def do_POST(self):
  208.         global total_count
  209.         log(f'HTTP POST {self.path} 开始处理')
  210.         try:
  211.             length = int(self.headers.get('Content-Length', 0))
  212.             raw = self.rfile.read(length)
  213.             data = json.loads(raw)
  214.             name = data.get('name', '').strip()
  215.             url = data.get('url', '').strip()
  216.             log(f'HTTP POST {self.path} body_len={length}')
  217.             log(f'JSON: name={name!r} url={url[:60]}...')
  218.             if not name or not url:
  219.                 log('拒绝任务:name 或 url 为空')
  220.                 self._cors()
  221.                 self._json({'status': 'error', 'msg': 'missing name or url'})
  222.                 return
  223.             log(f'>>> 收到任务: [{name}]')
  224.             log(f'    URL: {url[:80]}...')
  225.             self._cors()
  226.             self._json({'status': 'accepted', 'msg': f'已接收: {name}'})
  227.             log(f'已响应 accepted,提交后台下载...')
  228.             download_pool.submit(self._do_download, name, url)
  229.         except Exception as e:
  230.             tb = traceback.format_exc()
  231.             log(f'HTTP POST 异常: {e}')
  232.             log(f'异常堆栈: {tb}')
  233.             self._cors()
  234.             self._json({'status': 'error', 'msg': str(e)})
  235.     def _do_download(self, name, url):
  236.         """在单独线程中执行下载"""
  237.         global total_count
  238.         log(f'开始下载: [{name}]')
  239.         ok = download_one(name, url)
  240.         with count_lock:
  241.             if ok:
  242.                 total_count += 1
  243.             log(f'--- 任务结束: [{name}] {"成功" if ok else "失败"} (累计: {total_count}) ---')
  244.             log('等待新任务...')
  245.     def _cors(self):
  246.         self.send_response(200)
  247.         self.send_header('Access-Control-Allow-Origin', '*')
  248.         self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
  249.         self.send_header('Access-Control-Allow-Headers', 'Content-Type')
  250.         self.send_header('Content-Type', 'application/json')
  251.         self.end_headers()
  252.     def _json(self, obj):
  253.         body = json.dumps(obj, ensure_ascii=False).encode('utf-8')
  254.         self.wfile.write(body)
  255. def main():
  256.     ffmpeg = find_ffmpeg()
  257.     if not ffmpeg:
  258.         log('未找到 ffmpeg.exe,请放到本目录下')
  259.         sys.exit(1)
  260.     server = HTTPServer(('127.0.0.1', PORT), Handler)
  261.     log('=' * 50)
  262.     log('小鹅通 M3U8 视频下载器 启动')
  263.     log(f'ffmpeg: {ffmpeg}')
  264.     log(f'输出:   {OUT}')
  265.     log(f'端口:   localhost:{PORT}')
  266.     log('等待浏览器发送下载任务...')
  267.     log('在课程页面点击 "发送到下载" 即可')
  268.     log('按 Ctrl+C 退出')
  269.     log('=' * 50)
  270.     try:
  271.         server.serve_forever()
  272.     except KeyboardInterrupt:
  273.         log('\n退出。本次共下载 {} 个视频。'.format(total_count))
  274.         server.shutdown()
  275. if __name__ == '__main__':
  276.     main()
复制代码

下载地址

https://wwamb.lanzoul.com/ijN623u0mxji

© 版权声明

相关文章