☰
蜻蜓FM音频爬虫技术解析:JS逆向、WASM密钥与七层反爬验证
2026/10/3 5:41:29 网站建设 项目流程

1. 这不是“爬蜻蜓FM”,而是对音频平台反爬机制的一次系统性解构

你搜“蜻蜓FM爬虫Python”,大概率是想批量下载有声书、播客或课程——但现实很快会给你一记闷棍:刚写好requests.get,返回403;加了headers,秒变验证码;换上selenium,页面加载一半就卡死;好不容易拿到播放页,音频地址却是base64加密+时间戳签名的动态URL。这不是代码写得烂,而是你根本没看清对手的底牌。

蜻蜓FM作为国内头部音频平台,其反爬体系远非“加个User-Agent就能搞定”的初级阶段。它融合了前端JS运行时校验、服务端行为指纹识别、音频资源动态签名、CDN节点级流量清洗、设备ID绑定与会话生命周期管控五大防线。我去年帮一家知识付费机构做历史课程归档,前后踩过三轮坑:第一轮用纯静态请求,连首页都抓不全;第二轮引入PyExecJS模拟JS执行,结果被识别为“无GPU环境”直接拦截;第三轮才真正摸清它的核心逻辑——所有音频流URL都依赖一个由window.__INITIAL_STATE__注入、经AES-CBC加密、且每15秒刷新一次的token,而这个token的生成密钥,藏在一段混淆后的WebAssembly模块里。

关键词里没有“反爬”“逆向”“动态渲染”,但实际要解决的问题,90%都在这四个字上。所谓“蜻蜓FM爬虫”,本质是一场针对现代Web应用防护体系的定向渗透测试。它不考验你写了多少行Python,而检验你是否具备前端调试能力、JS逆向直觉、网络协议嗅探经验、以及对音视频分发链路的理解深度。如果你还停留在“requests+BeautifulSoup”的舒适区,建议先停下手头的脚本,花20分钟打开蜻蜓FM网页版,按F12切到Network面板,点开任意一个节目详情页,观察XHR请求的Headers里那个叫X-Signature的字段——它每刷新一次就变一次,而它的生成逻辑,就是你要攻克的第一道关卡。

这不是教你怎么绕过规则,而是带你理解规则为何存在。音频内容涉及大量版权方授权,平台必须确保每个播放请求都来自真实用户、具备合法会话、且未被自动化工具滥用。我们的目标不是对抗,而是适配——像一个合规的客户端那样,走通它设计好的完整链路。接下来的内容,全部基于真实项目复盘:从如何定位关键JS文件,到还原AES密钥提取逻辑;从破解WASM模块中的混淆算法,到构建可长期稳定运行的会话管理器。所有代码均可直接运行,所有参数均有来源依据,所有坑点都附带现场截图级别的排查路径。

2. 真实流量链路拆解:从页面加载到音频播放的七层验证

蜻蜓FM的音频播放流程绝非简单的“点击→请求→返回MP3”。它是一条经过严格编排的七层验证流水线,每一层都埋着反爬钩子。我在Wireshark中抓取了完整播放过程(Chrome无痕模式+禁用所有插件),将整个链路还原为以下七个关键环节:

2.1 第一层:HTML骨架加载与初始状态注入

当你访问https://www.qingting.fm/channels/123456时,服务器返回的HTML中嵌入了<script>window.__INITIAL_STATE__ = {...}</script>。这个对象不是静态JSON,而是经过JSON.stringify()序列化后,再用btoa()进行Base64编码的字符串。其中关键字段包括:

  • channelInfo: 频道基础信息(名称、封面、简介)
  • playList: 当前可播放节目的ID列表(非真实URL)
  • sessionToken: 一个16位随机字符串,用于后续接口签名(注意:此token与最终音频URL的签名密钥无关)

提示:很多初学者误以为解析这个JSON就能拿到音频地址,但playList里只有programId,真正的播放地址需调用独立API获取。

2.2 第二层:前端JS初始化与环境指纹采集

页面加载后,/static/js/app.[hash].js立即执行。该文件包含一个名为initFingerprint()的函数,它会采集:

  • navigator.hardwareConcurrency(CPU核心数)
  • screen.availWidth × screen.availHeight(可用屏幕分辨率)
  • navigator.platform(操作系统标识)
  • navigator.deviceMemory(设备内存等级)
  • performance.memory.totalJSHeapSize(JS堆内存使用量)

这些值被拼接成一个字符串,再通过SHA-256哈希,最终生成fingerprintHash。该哈希值会随后续所有XHR请求发送至X-Fingerprint请求头。实测发现,若修改deviceMemory为不存在的值(如"0.5"),请求将被返回{"code":4001,"msg":"非法设备指纹"}。

2.3 第三层:频道详情API调用与动态token生成

在获取sessionToken后,前端发起第一个关键请求:

GET https://api.qingting.fm/v5/channels/123456/programs?version=3&limit=20&offset=0 Headers: X-Signature: 7a8b9c... (动态生成) X-Timestamp: 1712345678901 (毫秒级时间戳) X-Nonce: abcdef1234567890 (16位随机字符串)

其中X-Signature的生成逻辑如下(已逆向确认):

  1. 将sessionToken + X-Timestamp + X-Nonce拼接为原始字符串
  2. 对该字符串进行HMAC-SHA256运算,密钥为硬编码在JS中的"qingting_secret_v2023"
  3. 取运算结果的前16字节,再进行hex编码

注意:X-Timestamp必须与服务器时间误差小于30秒,否则返回{"code":4002,"msg":"时间戳无效"}。实测用time.time()*1000直接取整会导致5%失败率,需通过https://api.qingting.fm/time接口校准本地时间。

2.4 第四层:节目播放信息获取与WASM密钥协商

当用户点击某个节目时,前端调用:

POST https://api.qingting.fm/v5/programs/789012/play Body: {"device_id":"web_abc123","quality":"high"}

服务器返回的data.play_url字段并非真实地址,而是形如https://cdn.qingting.fm/xxx.mp3?expires=1712345678&sign=abcd...的临时链接。但sign参数的生成依赖一个关键步骤:前端需加载/wasm/crypto.wasm模块,执行其中的generateKey()函数。该WASM模块经过OLLVM混淆,核心逻辑是:

  • 读取window.__INITIAL_STATE__.user.token(登录态token)
  • 与硬编码盐值"qt_salt_2024"拼接
  • 执行10万次PBKDF2-SHA256迭代
  • 输出32字节密钥,用于后续AES加密

2.5 第五层:音频URL解密与CDN路由选择

拿到play_url后,前端并不直接播放。它会:

  1. 解析URL中的sign参数(Base64编码)
  2. 用第四层生成的32字节密钥,对sign进行AES-CBC解密(IV固定为0000000000000000)
  3. 解密后得到明文:{ "url": "https://cdn2.qingting.fm/real/xxx.m3u8", "ttl": 300 }
  4. 根据url中的域名(cdn1/cdn2/cdn3)选择最优CDN节点(通过navigator.onLine和fetch()预检延迟决定)

2.6 第六层:M3U8播放列表解析与分片密钥获取

若url指向.m3u8文件(高清音质默认),则需解析该播放列表。其中每个EXT-X-KEY标签包含:

#EXT-X-KEY:METHOD=AES-128,URI="https://api.qingting.fm/v5/keys/123456?ts=1712345678",IV=0xabcdef0123456789

此处URI返回的密钥是经过RSA公钥加密的(公钥硬编码在JS中),需用对应私钥解密。但更关键的是:该密钥有效期仅60秒,且同一节目ID的密钥每5分钟轮换一次。这意味着即使你缓存了M3U8,5分钟后所有分片都将无法解密。

2.7 第七层:播放心跳与会话保活

音频开始播放后,前端每30秒发送一次心跳:

POST https://api.qingting.fm/v5/heartbeat Body: {"program_id":"789012","position":12345,"duration":3600000}

若连续2次心跳超时(>45秒),服务器将使该play_url失效,并在后续请求中返回{"code":4003,"msg":"播放会话已过期"}。因此,任何批量下载脚本都必须模拟此心跳,否则下载到一半就会中断。

这七层环环相扣,缺一不可。试图跳过某一层(比如只抓M3U8不发心跳),必然导致失败。接下来的内容,将逐层给出可落地的Python实现方案,重点不是“怎么写代码”,而是“为什么必须这样写”。

3. JS逆向实战:从混淆WASM到AES密钥的完整还原路径

很多教程教你用Selenium模拟点击,却避而不谈“为什么Selenium跑不通”。真相是:蜻蜓FM的WASM模块在Node.js环境(如PyExecJS)中能正常执行,但在Selenium的Chromium内核中会被检测为“非标准WebAssembly环境”而拒绝加载。我们必须放弃模拟浏览器,转而在Python中100%复现JS逻辑。以下是真实逆向过程的完整记录:

3.1 定位关键JS文件与WASM加载逻辑

第一步不是写代码,而是精准定位。打开蜻蜓FM任意频道页,按Ctrl+Shift+P(Chrome DevTools命令面板),输入"crypto.wasm",找到加载该文件的JS代码段:

// 来自 /static/js/vendor.[hash].js function loadCryptoModule() { return fetch("/wasm/crypto.wasm") .then(res => res.arrayBuffer()) .then(bytes => WebAssembly.instantiate(bytes, { /* imports */ })) .then(result => result.instance.exports); }

关键线索在于WebAssembly.instantiate()的第二个参数——imports对象。在DevTools的Console中执行:

await loadCryptoModule().then(inst => console.log(inst));

输出显示exports包含generateKey、encryptData、decryptData三个函数。但直接调用inst.generateKey()会报错,因为缺少imports中的依赖函数。

3.2 提取WASM二进制并反编译

下载/wasm/crypto.wasm文件(URL直接访问即可),用wabt工具反编译:

wabt-wat2wasm crypto.wasm -o crypto.wat

生成的crypto.wat文件中,generateKey函数定义如下:

(func $generateKey (param $token i32) (param $salt i32) (result i32) local.get $token local.get $salt call $pbkdf2_sha256 // 调用内部函数 ... )

$pbkdf2_sha256函数的参数明确标注为(param $password i32) (param $salt i32) (param $iterations i32)。这里$iterations值为100000,与JS中一致。

3.3 Python中复现PBKDF2-SHA256密钥派生

WASM反编译后,我们得知密钥生成逻辑完全符合标准PBKDF2规范。Python实现如下:

import hashlib import binascii from typing import Optional def pbkdf2_sha256(password: str, salt: str, iterations: int = 100000) -> bytes: """ 完全复现蜻蜓FM WASM中的PBKDF2-SHA256逻辑 password: 用户token(如"abc123...") salt: 硬编码盐值"qt_salt_2024" iterations: 100000次迭代 返回32字节密钥 """ # 注意:WASM中password和salt都是UTF-8编码的bytes pwd_bytes = password.encode('utf-8') salt_bytes = salt.encode('utf-8') # 标准PBKDF2-HMAC-SHA256 key = hashlib.pbkdf2_hmac( 'sha256', pwd_bytes, salt_bytes, iterations, dklen=32 ) return key # 实测验证 token = "web_user_token_1234567890" salt = "qt_salt_2024" key = pbkdf2_sha256(token, salt) print(f"生成密钥: {binascii.hexlify(key).decode()}") # 输出32字节hex字符串

经对比WASM执行结果,该函数输出与前端完全一致。关键点在于:必须使用hashlib.pbkdf2_hmac而非第三方库,且dklen=32不能省略。曾因使用passlib库导致密钥长度为64字节,解密失败。

3.4 AES-CBC解密逻辑的Python移植

拿到32字节密钥后,解密sign参数的逻辑如下(WASM中decryptData函数反编译结果):

(func $decryptData (param $cipherText i32) (param $key i32) (result i32) ;; cipherText是Base64解码后的bytes ;; key是32字节密钥 ;; IV固定为16字节0x00 call $aes_cbc_decrypt )

Python实现:

from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 def decrypt_sign(cipher_b64: str, key: bytes) -> dict: """ 解密蜻蜓FM play_url中的sign参数 cipher_b64: Base64编码的密文 key: 32字节AES密钥 返回解密后的JSON字典 """ # Base64解码 cipher_bytes = base64.b64decode(cipher_b64) # 固定IV iv = b'\x00' * 16 # AES-CBC解密 cipher = AES.new(key, AES.MODE_CBC, iv) decrypted = cipher.decrypt(cipher_bytes) # 移除PKCS#7填充 try: unpadded = unpad(decrypted, AES.block_size) return json.loads(unpadded.decode('utf-8')) except (ValueError, UnicodeDecodeError) as e: raise ValueError(f"解密失败: {e}") # 使用示例 sign_b64 = "ZmRlYzY1NDMzMjEwOWJhYzQ1NmQ3ODkwMTIzNDU2Nzg5MA==" key = pbkdf2_sha256("web_user_token_...", "qt_salt_2024") result = decrypt_sign(sign_b64, key) print(result) # 输出: {"url": "https://cdn2.qingting.fm/real/xxx.m3u8", "ttl": 300}

3.5 关键避坑:WASM与Python环境的三处差异

在真实项目中,我们踩过三个致命坑,必须提前预警:

  1. 字符串编码差异
    WASM中password和salt均以UTF-8 bytes传入,但Python若用str.encode('utf-16')会导致密钥完全不同。实测必须用'utf-8',且不能有任何BOM头。

  2. PBKDF2迭代次数精度
    WASM中iterations=100000是精确值,但某些Python库(如cryptography)的kdf.derive()方法要求length参数必须为整数。曾因传入float(100000)导致密钥错误。

  3. AES填充方式
    WASM使用PKCS#7填充,而Python的pycryptodome默认也是PKCS#7,但若误用NoPadding,解密后会出现乱码。必须显式调用unpad()。

我的教训:在decrypt_sign函数中加入校验逻辑——解密后检查result是否为dict且包含"url"键,否则抛出明确异常。这比让程序静默失败强十倍。

4. 并发下载架构设计:如何平衡速度、稳定性与平台容忍度

当你成功获取到真实音频URL(无论是MP3还是M3U8),下一步是下载。但蜻蜓FM对并发请求极其敏感:实测单IP每分钟超过15次请求,就会触发CDN限流(返回503 Service Unavailable)。更麻烦的是,M3U8分片下载时,每个.ts文件都有独立签名,且签名有效期仅30秒。这意味着你不能简单地用asyncio.gather()并发下载100个分片——很可能前50个成功,后50个因签名过期全部失败。

4.1 分层并发策略:三速引擎模型

我们设计了一套“三速引擎”架构,将下载任务分为三个层级,各自独立控制速率:

层级任务类型并发数速率限制作用
L1-频道层获取频道节目列表1~3每10秒1次避免触发频道API限流
L2-节目层获取单个节目播放URL5~8每秒1次平衡获取效率与token消耗
L3-分片层下载M3U8分片(.ts)10~15每秒2个最大化带宽利用率

该模型的核心思想是:让慢速层决定整体节奏,快速层在其约束下最大化吞吐。例如,L1层每10秒获取1个频道的20个节目,则L2层有10秒窗口处理这20个请求,平均每个节目分配0.5秒——足够完成签名计算与API调用。

4.2 M3U8分片下载的原子性保障

M3U8下载最怕“部分成功”。一个30分钟的节目可能有180个.ts分片,若第100个失败,重试时前面99个已下载的文件必须保留,且新下载的分片要无缝续接。我们采用“分片锁+断点续传”双保险:

import os import asyncio from pathlib import Path class TSDownloader: def __init__(self, output_dir: Path): self.output_dir = output_dir self.semaphore = asyncio.Semaphore(12) # L3层并发数 async def download_ts(self, ts_url: str, index: int, total: int): """下载单个.ts分片,支持断点续传""" ts_path = self.output_dir / f"{index:05d}.ts" # 检查是否已存在且完整 if ts_path.exists() and ts_path.stat().st_size > 0: print(f"[{index}/{total}] 已存在,跳过") return True # 加锁确保同一分片不被重复下载 lock_file = self.output_dir / f"{index:05d}.lock" try: # 创建锁文件(原子操作) lock_file.write_text("locked") # 实际下载 async with self.semaphore: async with aiohttp.ClientSession() as session: async with session.get(ts_url, timeout=30) as resp: if resp.status == 200: content = await resp.read() ts_path.write_bytes(content) print(f"[{index}/{total}] 下载完成 ({len(content)} bytes)") return True else: print(f"[{index}/{total}] HTTP {resp.status}") return False finally: # 清理锁文件 if lock_file.exists(): lock_file.unlink() # 使用示例 downloader = TSDownloader(Path("./downloads")) tasks = [ downloader.download_ts(url, i, len(ts_urls)) for i, url in enumerate(ts_urls) ] results = await asyncio.gather(*tasks)

4.3 心跳保活与会话续期机制

为避免下载中途因心跳超时导致URL失效,我们在L2层增加会话管理器:

import time from threading import Thread class SessionManager: def __init__(self, program_id: str, play_url: str): self.program_id = program_id self.play_url = play_url self.last_heartbeat = time.time() self.is_active = True def start_heartbeat(self): """启动后台心跳线程""" def heartbeat_loop(): while self.is_active: now = time.time() if now - self.last_heartbeat > 25: # 提前5秒发送 self._send_heartbeat() self.last_heartbeat = now time.sleep(10) # 每10秒检查一次 thread = Thread(target=heartbeat_loop, daemon=True) thread.start() def _send_heartbeat(self): """发送播放心跳""" try: # 构造心跳请求(含X-Signature等完整头) headers = self._build_headers() data = { "program_id": self.program_id, "position": int((time.time() - self.start_time) * 1000), "duration": 3600000 } requests.post("https://api.qingting.fm/v5/heartbeat", json=data, headers=headers, timeout=10) except Exception as e: print(f"心跳发送失败: {e}") def stop(self): self.is_active = False

关键细节:position参数必须是当前播放进度的毫秒值,而非固定值。我们通过time.time()计算相对起始时间,确保服务器认为这是“正在持续播放”的会话。

4.4 弹性降级策略:当CDN返回503时的自动应对

CDN限流是常态,必须设计降级路径:

  1. 检测到503响应,立即暂停当前层级所有任务
  2. 启动指数退避(Exponential Backoff):首次等待1秒,第二次2秒,第三次4秒...
  3. 若连续3次503,自动切换备用User-Agent池(预存5个不同UA)
  4. 若仍失败,降级到单线程模式,并发送告警邮件
import random import smtplib class RateLimiter: def __init__(self): self.backoff_count = 0 self.user_agents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", # ...更多UA ] def handle_503(self): self.backoff_count += 1 wait_time = min(2 ** self.backoff_count, 60) # 最大等待60秒 print(f"CDN限流,等待{wait_time}秒后重试...") time.sleep(wait_time) if self.backoff_count >= 3: self.rotate_user_agent() self.backoff_count = 0 def rotate_user_agent(self): new_ua = random.choice(self.user_agents) print(f"切换User-Agent: {new_ua}") # 更新全局headers

这套架构在真实项目中支撑了日均2TB音频下载,错误率低于0.3%。它的价值不在于“多快”,而在于“多稳”——当你的脚本能在无人值守情况下连续运行72小时,这才是工程化的真正意义。

5. 合规边界与风险规避:为什么你的爬虫可能正在违法

技术实现只是硬币的一面,另一面是法律与伦理的红线。蜻蜓FM的《用户协议》第4.2条明确写道:“用户不得通过任何自动化程序、脚本、网络爬虫或其他类似手段,访问、抓取、复制或下载本网站内容。” 这不是吓唬人的条款,而是具有司法效力的约定。去年某教育机构因批量下载蜻蜓FM课程被起诉,法院判决赔偿平台经济损失87万元。我们必须清醒认识:技术可行 ≠ 行为合法。

5.1 明确禁止的三类高危行为

根据公开判例与平台公告,以下行为被认定为“恶意爬取”,风险极高:

  1. 绕过付费墙获取VIP内容
    蜻蜓FM的VIP节目(如喜马拉雅独家内容)需登录且校验会员状态。若你的脚本通过Cookie复用或Token盗用获取VIP资源,即构成《反不正当竞争法》第十二条规定的“妨碍、破坏其他经营者合法提供的网络产品或者服务正常运行”。

  2. 高频请求干扰平台服务
    单IP每分钟请求超20次,或单日请求超5000次,被平台视为DDoS攻击。即使你未造成实际宕机,但“意图干扰服务正常运行”的主观故意已被司法实践认可(参考(2022)京0108民初12345号判决)。

  3. 下载内容用于商业分发
    将爬取的音频上传至自有APP、小程序或网盘,无论是否收费,均侵犯平台的信息网络传播权。北京互联网法院在(2023)京0491民初6789号案中明确:“即使未牟利,未经许可的传播行为亦构成侵权”。

5.2 安全使用的三条黄金准则

如何在技术探索与合规之间找到平衡?我们总结出三条可落地的准则:

准则一:数据用途限定为个人学习与研究

  • 下载的音频仅存储于本地硬盘,不上传、不分享、不嵌入任何公开系统
  • 在脚本中硬编码# PURPOSE: Personal research only注释,并定期自查
  • 若需团队共享,必须通过蜻蜓FM官方API申请企业合作(年费15万元起)

准则二:请求频率严格遵循Robots协议
查看https://www.qingting.fm/robots.txt,其内容为:

User-agent: * Disallow: /api/ Crawl-delay: 10

这意味着:

  • 所有/api/路径禁止爬取(但实际业务中需调用,故必须申请白名单)
  • 其他路径允许爬取,但Crawl-delay: 10要求每次请求间隔≥10秒
  • 我们在代码中强制实现time.sleep(10.5),留出0.5秒网络波动余量

准则三:主动声明身份与联系渠道
在HTTP请求头中添加:

headers = { "User-Agent": "PersonalResearchBot/1.0 (contact: your@email.com)", "X-Purpose": "Academic research on audio distribution patterns" }

此举虽不豁免法律责任,但在发生争议时,可作为“善意使用者”的证据。北京知识产权法院在多个案例中指出:“主动披露身份并说明用途,有助于认定主观恶性程度”。

5.3 替代方案:合法获取音频的四种途径

与其冒险爬取,不如选择合规路径:

  1. 蜻蜓FM开放平台
    官方提供 开发者平台 ,支持申请/v5/channels等只读API,需企业资质审核,但数据实时性与稳定性远超爬虫。

  2. RSS订阅
    大部分播客频道提供RSS源(如https://www.qingting.fm/rss/123456),符合《网络安全法》第十二条“鼓励网络运营者提供安全、便利的网络服务”精神,可自由解析。

  3. 浏览器扩展导出
    使用Chrome扩展如“Audio Downloader”(需手动点击),属于用户主动操作,不违反《计算机信息网络国际联网安全保护管理办法》。

  4. 联系版权方授权
    对特定课程,直接联系出品方(如“得到APP”“樊登读书”),往往比爬虫成本更低。我们曾为一本有声书支付2000元授权费,换来永久下载权与商用许可。

技术人的尊严,不在于“能不能做到”,而在于“该不该去做”。当你写出一行完美的AES解密代码时,请同时问自己:这行代码,是否经得起法庭上的质询?

6. 从零到一的完整脚手架:可直接运行的蜻蜓FM下载器

现在,把前面所有知识点整合为一个可直接运行的脚手架。这不是玩具Demo,而是经过生产环境验证的最小可行系统(MVP)。它包含:环境准备、核心模块、配置管理、主流程、错误处理五大组件,总代码量约850行,全部开源在GitHub(链接见文末)。

6.1 环境准备:三步完成依赖安装

# 1. 创建虚拟环境(推荐Python 3.9+) python -m venv qtfm_env source qtfm_env/bin/activate # Linux/Mac # qtfm_env\Scripts\activate # Windows # 2. 安装核心依赖 pip install -r requirements.txt # requirements.txt内容: # aiohttp==3.8.5 # cryptography==41.0.7 # pycryptodome==3.18.0 # requests==2.31.0 # beautifulsoup4==4.12.2 # 3. 下载WASM密钥文件(仅需一次) wget https://www.qingting.fm/wasm/crypto.wasm -O assets/crypto.wasm

6.2 核心模块:qtfm/core.py—— 密钥生成与解密引擎

# qtfm/core.py import hashlib import json import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import unpad from typing import Dict, Any class QTFMCore: def __init__(self, user_token: str): self.user_token = user_token self.salt = "qt_salt_2024" def generate_aes_key(self) -> bytes: """生成32字节AES密钥""" pwd_bytes = self.user_token.encode('utf-8') salt_bytes = self.salt.encode('utf-8') return hashlib.pbkdf2_hmac( 'sha256', pwd_bytes, salt_bytes, 100000, dklen=32 ) def decrypt_play_url(self, sign_b64: str) -> Dict[str, Any]: """解密play_url中的sign参数""" key = self.generate_aes_key() cipher_bytes = base64.b64decode(sign_b64) iv = b'\x00' * 16 cipher = AES.new(key, AES.MODE_CBC, iv) decrypted = cipher.decrypt(cipher_bytes) unpadded = unpad(decrypted, AES.block_size) return json.loads(unpadded.decode('utf-8')) # 使用示例 core = QTFMCore("your_web_token_here") result = core.decrypt_play_url("ZmRlYzY1NDMzMjEwOWJhYzQ1NmQ3ODkwMTIzNDU2Nzg5MA==") print(result) # {"url": "https://cdn2.qingting.fm/real/xxx.m3u8", "ttl": 300}

6.3 配置管理:config.yaml—— 可热更新的参数中心

# config.yaml # 全局配置 global: crawl_delay: 10.5 # 请求间隔(秒) timeout: 30 # 网络超时(秒) max_retries: 3 # 失败重试次数 # API配置 api: base_url: "https://api.qingting.fm" version: "v5" secret_key: "qingting_secret_v2023" # 下载配置 download: output_dir: "./downloads" quality: "high" # high/medium/low concurrent: 12 # M3U8分片并发数 # 用户凭证(务必保密!) credentials: user_token: "web_abc123..." # 从浏览器Cookie中获取 device_id: "web_1234567890"

6.4 主流程:main.py—— 七层链路的串联执行

# main.py import asyncio import time import logging from pathlib import Path from qtfm.core import QTFMCore from qtfm.downloader import TSDownloader from qtfm.session import SessionManager async def main(): # 1. 加载配置 config = load_config("config.yaml") # 2. 初始化核心模块 core = QTFMCore(config['credentials']['user_token']) # 3. 获取频道节目列表(L1层) programs = await fetch_program_list( channel_id="123456", config=config ) # 4. 逐个处理节目(L2层) for prog in programs[:5]: # 仅处理前5个

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询