
这个标题是我在某技术交流群里看到的每日爬虫训练任务之一。任务内容很直接从某个影视站点拿到影片的真实播放链接加密类型标注为 jsjiamiv7难度适中。我花了一个晚上把整个流程捋完从抓包定位到还原加密逻辑再到用 Python 落地整个过程挺有代表性适合作为 JS 逆向和 Python 爬虫结合练习的参考案例。如果你正在找练手项目或者工作中刚好遇到这类链路这篇内容应该能帮你省掉不少排查时间。先说结论影视站点做“链接加密”的目标很明确就是不想让拿到网页源码的人直接看到真实播放地址。所谓 jsjiamiv7是这个站点对内置播放器地址做的一层 JS 混淆加密属于前端加密中比较典型的一类。难度的“适中”主要体现在它不像 v10、v12 那样自带复杂的浏览器环境校验也不像 v1、v2 那样简单到只是 Base64 套一层。v7 的套路是加密参数藏在 JS 函数里需要动手定位和还原但不需要上虚拟机补环境。1. 案例背景与整体设计思路1.1 目标场景与需求拆解影视站的页面结构通常分成两层。列表页能看到影片名称、封面、简介播放页则内嵌一个播放器播放器要播放视频必须拿到真实的视频文件地址这个地址可以是 .mp4 直链也可以是 .m3u8 的流媒体链接。但你在浏览器里直接打开播放页按 F12 查看网络请求会发现真正的视频地址并不出现在初始 HTML 里它藏在某个、或者说某几个关键接口的响应中而且往往不是明文。这背后的原因不复杂。如果真实链接直接写在 HTML 里任何人都可以轻松把它提取出来盗链成本会非常低。所以站点在前端加了一层加密用 JS 在浏览器运行时解密然后动态塞给播放器。我们要做的就是在 Python 里模拟这个过程先拿到密文再还原出解密逻辑最后在 Python 端执行同样的算法拿到明文地址。用一句话概括这个案例的核心链路列表页获取视频ID - 调用加密接口获取密文 - 逆向JS解密函数 - Python还原解密算法 - 提取真实播放链接整个链路中抓包定位是基础JS 逆向是核心Python 落地是最终目的。步骤之间是强依赖的任何一环卡住后面都跑不通。1.2 为什么 jsjiamiv7 难度适中我把影视站常见的加密类型粗略分过几档。v1 到 v3 是入门档经常直接把密文写在页面里解密函数一眼就能认出是 Base64 包一层或者只是简单字符替换适合刚接触逆向的人拿来练手。v4 到 v6 开始引入动态参数比如时间戳参与运算、字符串翻转后再编码需要稍微动点脑子。v7 这个档位从我的实战观察来看它有几个明显特征加密入口藏在独立 JS 文件中不再写在页面内联 script 里算法是组合的通常是“编码 位移/异或 二次编码”的混合体密文里带标识字符用于区分不同的加密分支不依赖浏览器环境Node.js 或纯 Python 都能还原不需要过反调试、过环境检测。这几点决定了它“适中”的定位。比纯 Base64 复杂因为需要分析代码逻辑但比商业级前端框架简单因为不需要为了环境检测补一堆浏览器属性。适合已经能独立分析请求、但还没接触过复杂混淆的开发者。2. 抓包分析定位加密接口与密文来源2.1 从播放页到接口的完整抓包流程拿到一个目标站第一步永远是分析请求而不是急着打开 JS 文件看代码。打开浏览器开发者工具切到 Network 面板勾选 Preserve log 保留日志然后手动进入播放页。关键操作点是播放页嵌入的视频播放器通常会在页面加载完成后自动发起请求但有些站点做了节流需要点击一次播放按钮才触发。所以我在实战中习惯先清空日志再点击播放这样能够很干净地看到这一瞬间的所有请求。实际观察到的请求序列一般是这样的播放页 HTML 文档请求含视频ID - 统计上报接口忽略 - 播放参数接口 /api/play/config 这类返回 JSON内含密文 - 视频分片或直链请求真正播放地址我在这次案例里重点只关注/api/play/config这个接口。为什么因为它是从“页面”到“真实地址”的中间桥梁。返回的 JSON 里有加密字段比如常见的url、link、encrypt后面判断解密逻辑时都是从这里拿数据。2.2 如何快速锁定目标响应Network 面板里几十上百个请求如何判断哪个是真正需要逆向的我有一套快速筛选法第一步过滤 XHR/Fetch 请求。播放器在线播放基本靠这两类请求去掉图片、字体、CSS 这些干扰项。第二步看响应类型。凡是要参与解密的接口返回的 JSON 通常是结构化数据这个特征非常明显。第三步搜索关键字。在响应体里搜m3u8、mp4、http这些关键词。如果返回的响应能直接搜到这些那它大概率是明文接口如果搜不到反而说明它可能加密了——搜索不到就对了密文里当然没有这些关键词。第四步确认请求的触发时机。点播放按钮后才出现的请求优先级最高。在这次案例中接口返回的数据结构大概长这样我隐去了真实域名用示例代替{ code: 0, msg: ok, data: { vid: abc123, encrypt: M84f2#kd9s0fk3..., flag: v7 } }注意中间的flag字段值为v7。这就是为什么这个案例被归类为 jsjiamiv7 类型的原因。站点在响应里主动声明了加密版本后面找解密函数时就有了方向直接找 v7 对应的分支代码。2.3 顺着字段名定位 JS 函数拿到密文后需要回到 JS 源码里找到解密入口。传统做法是把播放页相关的 JS 文件逐个下载全局搜索关键词。首选搜索关键词是响应里的字段名称比如encrypt、flag。因为 JS 在解密时有一步一定是“从对象里取字段”变量名通常是同名的。其次搜索的是特征函数名比如decode、decrypt、restore这类不过这类词太常见干扰结果很多我会加限定条件比如decrypt(带括号、带赋值把注释和无关引用过滤掉。更快的办法是搜索v7字符串本身。因为我看到响应里flag字段值是v7解密逻辑势必会有一个分支判断if (data.flag v7) { // v7 解密逻辑 realUrl v7Decrypt(data.encrypt); }沿着这个分支直接定位到v7Decrypt函数定义。实践下来这种方式比全文搜索decrypt高效得多尤其是目标站点把多个版本的加密函数都放在同一个文件里时搜索版本号可以直接避开其他版本代码的干扰。2.4 v7 函数的特征与初步阅读定位到函数后先把函数体整体读一遍不要在细节上恋战。我的习惯是先整理出函数骨架输入参数是什么、中间调用了哪些辅助函数、返回值是什么、有没有atob、charCodeAt、fromCharCode、substring这类高频方法。v7 的解密函数一般长这样我做了一个示例简化function v7Decrypt(str) { var seed parseInt(str.slice(0, 2), 16); var body str.slice(2); var decoded atob(body); var result ; for (var i 0; i decoded.length; i) { var code decoded.charCodeAt(i) ^ seed; result String.fromCharCode(code); } return result; }虽然具体站点代码千差万别但 v7 这一档的套路基本逃离不了这个框架密文头部取种子或密钥剩余部分是编码后内容经过一层编码还原和一层异或/位移最终输出明文链接。看到这里整个加密的还原思路已经清晰了。接下来要做的就是把这个逻辑移植到 Python 中。3. JS 加密还原与 Python 落地实现3.1 逐行拆解 v7 加密规则我反复强调一个观点JS 逆向不是把代码抄一遍而是把算法逻辑理解后“翻译”成目标语言。这个案例里的解密逻辑拆成三步就能清清楚楚第一步从密文字符串头部拿到种子。注意很多站点从来不用明文keyxxx这种结构传递密钥而是把种子直接编码在密文字符串里。用parseInt解析十六进制是常见的做法因为0-9a-f是安全字符放在字符串开头不会引来麻烦。第二步对剩余部分做一次编码解码。v7 系列最常见的是 Base64但我也遇到过 URL 编码和多层 Base64 嵌套的变种。判断方法很简单把密文去掉头部后扔给在线解码工具试一次如果结果是可见的乱码且字符范围在可打印 ASCII 内基本就可以确认是 Base64。第三步逐字符做异或操作。JS 里的charCodeAt(i) ^ seed对应 Python 里的ord(c) ^ seedString.fromCharCode对应chr()。异或操作是对称的加密时异或一次解密时异或一次就能还原这是这类算法被广泛使用的原因。还有一个小细节值得注意部分站点在异或之后还会对结果做一次replace比如把某些字符替换成合法 URL 字符或者补全缺失的协议头。这一步在分析时容易被忽略我最初就是因为没有处理这个替换导致提取出来的链接缺了一段播放器报 404。3.2 用 Python 重写 v7 解密算法理解了规则后Python 端实现就非常直接了。这里我给出一个完整的可执行脚本它模拟了从请求接口到解密出真实链接的完整流程。代码中的域名和相关字段做了脱敏处理你拿到的目标站字段名可能不同注意按实际情况调整。import requests import base64 import re def decrypt_v7(encrypt_str: str) - str: 还原 jsjiamiv7 解密逻辑 if not encrypt_str: return # 第一步从密文头部提取种子十六进制 seed_str encrypt_str[:2] seed int(seed_str, 16) # 第二步去除头部后对剩余部分做 Base64 解码 body encrypt_str[2:] # 部分站点在 Base64 前会先做一次 URL 解码这里做兼容处理 decoded base64.b64decode(body).decode(utf-8, errorsignore) # 第三步逐字符异或还原 chars [] for ch in decoded: chars.append(chr(ord(ch) ^ seed)) decrypted .join(chars) # 第四步站点在异或后可能做字符替换根据实际观察补充 # 典型场景把加密中的特殊字符还原成 URL 安全字符 decrypted decrypted.replace(#47;, /).replace(\\/, /) return decrypted def get_real_url(page_url: str) - str: 从播放页出发获取真实播放地址 session requests.Session() headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: page_url, } # 1. 请求播放页获取视频ID resp session.get(page_url, headersheaders, timeout10) vid_match re.search(rvid\s*:\s*([^]), resp.text) if not vid_match: raise RuntimeError(未从播放页提取到视频ID) vid vid_match.group(1) # 2. 请求加密接口获取密文 config_api https://api.example.com/play/config params {vid: vid} config_resp session.get(config_api, paramsparams, headersheaders, timeout10) data config_resp.json() encrypt_str data[data][encrypt] flag data[data].get(flag, ) if flag ! v7: # 如果站点升级了版本这里可以按 version 分发到不同的解密函数 raise ValueError(f未支持的加密版本: {flag}) # 3. 解密并提取链接 plain_text decrypt_v7(encrypt_str) url_match re.search(rhttps?://[^\s\]\.(?:m3u8|mp4)[^\s\]*, plain_text) if not url_match: # 有些场景下链接是相对路径需要拼接 url_match re.search(r([^\s\]\.(?:m3u8|mp4)), plain_text) if url_match: return url_match.group(1) raise RuntimeError(解密结果中未找到播放链接) return url_match.group(0) if __name__ __main__: test_url https://example.com/play/12345.html real_url get_real_url(test_url) print(真实播放地址:, real_url)这个脚本有三个关键点值得单独说明。第一点errorsignore在解码时用得很巧妙。因为密文里如果包含非 UTF-8 字节Python 的decode会直接抛异常但 JS 的atob不会。加上errorsignore后行为模式和 JS 保持了一致不会因为个别异常字符导致整个解密中断。第二点字符替换规则一定要核对清楚。我在实际项目里遇到过站点把\/写成\\/还有的把%2F也参与替换如果没有在解密后做这一步出来的链接有时候能拼对有时候就断了。这个可以通过对比浏览器控制台里解密结果和 Python 解密结果来校准。第三点正则提取链接时不能只写m3u8一种格式。现在越来越多的影视源是 mp4 直链还有些返回的是带 token 的防盗链链接所以我在正则里同时覆盖了两种后缀并且用[^\s\]来保证匹配到完整 URL。3.3 从解密结果到真实可播链接的验证解密出链接之后强烈建议先手动验证一次再写进正式代码。我的验证步骤是直接在浏览器打开解出来的链接。如果浏览器能播放且画面正常说明密文还原成功。如果浏览器无法访问可能是链接需要带 Referer 或者 User-Agent 头先别急着怀疑解密逻辑。把解密结果粘贴到 HLS 流检测工具里看分片信息。m3u8 链接玩的是“分片播放”如果获取到的 m3u8 内容本身就是完整播放列表说明链接有效如果返回 403大概率是防盗链配置需要额外带 Referer。影视站通常一看 Referer 不是本站域名就直接拒绝这个坑在爬虫里非常常见。我在这次案例里就踩过一次解密出的 m3u8 链接是对的但直接用 requests 去请求时返回 403。后来加上Referer: https://example.com之后状态码立刻变为 200。这个坑在后续代码里一定要提前规避如果不带 Referer 就去抓流很容易误判是解密失败。# 请求 m3u8 分片列表时必须带 Referer这是防盗链的基本策略 m3u8_headers { User-Agent: headers[User-Agent], Referer: https://example.com/, } resp session.get(real_url, headersm3u8_headers, timeout15) if resp.status_code 200: print(m3u8列表获取成功共发现分片:, len(resp.text.splitlines()))3.4 代码结构上的灵活性设计写爬虫代码时我会刻意把“解密”和“请求”拆成两个独立模块。原因很直接影视站的反爬升级往往只改加密算法不改接口结构或者是只改接口域名不改算法。如果把两者揉在一起每次站点改动都要重写整个函数维护成本会迅速上升。比较好的做法是维护一个decryptors字典key 是版本号value 是对应的解密函数DECRYPTORS { v7: decrypt_v7, # 后续如果站点升级到 v8、v9 # v8: decrypt_v8, # v9: decrypt_v9, } def get_decryptor(flag: str): return DECRYPTORS.get(flag)这样当目标站从 v7 升到 v8 时只需要新增一个解密函数主流程几乎不用动。这种可扩展的写法在长期维护的爬虫脚本里价值很高尤其是在你手里有多个站点要维护时。4. 常见问题与排查技巧实录4.1 高频报错速查表实战中总会遇到各种奇奇怪怪的问题我整理了一份速查表都是这次案例中或同类案例中真实遇到过的。问题现象原因分析解决方案Base64 解码抛异常Invalid base64-encoded string密文里包含 URL 转义字符直接解码失败先urllib.parse.unquote再base64.b64decode解密结果看起来是乱码不是网址异或用的种子不对通常是把十六进制0x3f解析成了十进制63两个值数值上其实一样但如果是多位种子可能取错位核对 JS 里parseInt(str.slice(0, N), 16)的切片长度确保和 Python 一致解密输出很长但正则提取不到m3u8链接可能被拼接成//domain/path/index.m3u8?且被非法字符干扰调整正则把?后的参数一起匹配进去[^\s]效果更稳用代码请求 m3u8 链接返回 403防盗链校验依赖 Referer 头请求时分发源站的 Host 和 Referer解密链接正确但播放几秒后断开有些站用的是动态签名链接里的 token 有时效性保证从解密到请求的时间间隔尽量短做好链接缓存比如几分钟内复用同一链接4.2 容易被忽略的细节坑有些问题的排查成本很高根源却很简单。我把这类问题单独列出来因为这些坑几乎每次都在。第一字符集导致的长度不一致。JS 里的.length和 Python 里的len()在涉及 Unicode 时表现不同。比如atob得到的结果中如果包含中文字符JS 里用charCodeAt取到的码点和 Pythonord取到的不一定一致实际遇到的情况多为反爬故意混入非 ASCII 字符。这时候比较稳妥的做法是统一以字节为单位操作而不是字符。第二请求头顺序导致的接口 400 报错。某些站点的接口有严格的请求头校验不只校验 Content-Type还校验 Accept 的顺序。用 requests 默认头偶尔会触发 400最直接的办法就是把浏览器请求头完整复制过来不要精简。第三密文里隐藏了空白字符。在复制密文或从 JSON 中提取密文时如果前后有看不见的空格或换行符直接进入解密函数会导致结果完全错误。强烈建议在解密前先做一次strip()并打印密文前后几个字符做肉眼校验。第四站点的加密接口可能做了时间戳校验。我遇到过一类 v7 变种密文不是请求时一次性返回的而是接口在响应里额外给了一个ts字段解密时需要把 ts 拼接进密文再参与运算。这种情况如果直接拿接口返回的encrypt字段解永远解不出正确链接。处理方法是回 JS 里看解密函数的调用点观察它是否把其他响应字段也作为参数传了进去。4.3 调试 JS 代码的偷懒技巧虽然最终落地是 Python但在调试阶段直接用浏览器控制台执行目标 JS 函数是最快的验证方式。打开 Sources 面板找到加密 JS 文件在函数入口处打一个断点然后刷新页面。当执行到断点时在控制台手动调用v7Decrypt函数并传入自己的测试密文就能立刻看到解密输出。这种方法比在 Python 里反复试错效率高得多。因为可以直接观察 JS 内部变量的值比如seed是多少、decoded的中间结果是什么这些中间量就是 Python 端调试的对照样本。把 JS 控制台里每一步的输出记下来再在 Python 里比对很快就能定位出错在哪一步。如果目标函数在压缩代码里不好读可以用美化器格式化后再阅读。格式化后变量名虽然还是短变量但结构会清晰很多至少能看出函数边界和调用关系。4.4 加密难度升级的预判与应对我做了这么多影视站案例最大的体会是v7 不是终点你永远不知道明天会不会出现 v8。站点升级加密时通常不是重写全部算法而是在旧基础上加一层或者改一个参数位置。基于这个判断我总结了三个升级特征的提前应对方法一是算法替换类升级。比如 v7 用 Base64v8 改成 Hex。遇到这种情况解密函数的核心步骤要改但外层框架不变。所以在写解密函数时应该把“编码解码”和“异或/位移”拆成两个子函数方便替换。二是密钥动态化升级。v7 的种子可能固定写死在 JS 里v8 可能改为由接口动态下发。应对方法是在请求接口时把响应里所有字段都存下来供解密函数备用而不是只取一个encrypt字段。很多人在逆向时习惯性忽略多余字段当站点升级时才发现缺少必要参数不得不重新抓包分析。三是加入环境检测的升级。v7 档次不需要环境检测但 v8 可能会检查navigator.webdriver或者window的某个属性。如果遇到这类升级纯 Python 还原的难度会显著提高这时一般会选择用 JS 运行时执行原函数。我的建议是提前做好技术储备先用最简单的方式跑通再逐步完善不要为了追求“纯 Python 还原”而过度设计。5. 项目影响范围与学习延伸这个案例做完之后我复盘了一下发现它对爬虫学习路径的价值比“拿到一个链接”更大。首先是 JS 逆向的入门思维得到了完整训练从前端代码阅读到算法翻译再到跨语言复现这是很多教程没有成体系讲清楚的。其次是异常排查能力得到了实打实的提升解密失败时如何对照中间量快速定位这种经验正是工程师日常工作中最需要的。最后是代码设计意识把加密版本做成可插拔的模块这个习惯直接受益于后续其他站点的爬取开发。从我的经验看一个“难度适中”的案例最适合用来训练的是“调试方法论”。我不止一次看到有人拿着一个复杂加密的案例反复研究但始终不得要领原因就是他们跳过了中等级别案例的训练直接挑战高难度的环境检测和混淆。如果你还处在爬虫进阶阶段建议先把 v7 这一类练透把“抓包 - 定位 - 还原 - 落地”这四个动作内化成肌肉记忆再往上接触更复杂的算法时会从容很多。另外补充一个实际使用中的小技巧解密后的播放链接尽量不要长时间缓存。影视站会在视频地址上做时效控制有的链接几分钟就失效有的加入防盗链后过期时间更短。我在脚本里做了一个链接缓存模块默认缓存 5 分钟超时后重新走一遍加密接口。这样既不会频繁请求目标站又能保证拿到的链接始终有效。控制好请求频率既是对目标站的礼貌也能降低被限制的风险。这次 v7 案例虽然已经跑通但我知道这类站点大概率会继续升级策略。好在我已经把解密函数单独封装即使哪天站点换上新的加密方式我也只需要在DECRYPTORS字典中加一个新的分支改动的代码量非常有限。这也算是我这次练习最大的实际收获。