☰
JS逆向与Python爬虫:破解jsjiamiv7获取影视站真实播放链接
2026/10/19 12:42:17 网站建设 项目流程

这个标题是我在某技术交流群里看到的每日爬虫训练任务之一。任务内容很直接:从某个影视站点拿到影片的真实播放链接,加密类型标注为 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 逆向不是把代码抄一遍,而是把算法逻辑理解后“翻译”成目标语言。这个案例里的解密逻辑,拆成三步就能清清楚楚:

第一步,从密文字符串头部拿到种子。注意很多站点从来不用明文key=xxx这种结构传递密钥,而是把种子直接编码在密文字符串里。用parseInt解析十六进制是常见的做法,因为0-9a-f是安全字符,放在字符串开头不会引来麻烦。

第二步,对剩余部分做一次编码解码。v7 系列最常见的是 Base64,但我也遇到过 URL 编码和多层 Base64 嵌套的变种。判断方法很简单:把密文去掉头部后扔给在线解码工具试一次,如果结果是可见的乱码且字符范围在可打印 ASCII 内,基本就可以确认是 Base64。

第三步,逐字符做异或操作。JS 里的charCodeAt(i) ^ seed对应 Python 里的ord(c) ^ seed,String.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", errors="ignore") # 第三步:逐字符异或还原 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, headers=headers, timeout=10) vid_match = re.search(r'"vid"\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, params=params, headers=headers, timeout=10) 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(r"https?://[^\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)

这个脚本有三个关键点值得单独说明。

第一点,errors="ignore"在解码时用得很巧妙。因为密文里如果包含非 UTF-8 字节,Python 的decode会直接抛异常,但 JS 的atob不会。加上errors="ignore"后,行为模式和 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, headers=m3u8_headers, timeout=15) 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 用 Base64,v8 改成 Hex。遇到这种情况,解密函数的核心步骤要改,但外层框架不变。所以在写解密函数时,应该把“编码解码”和“异或/位移”拆成两个子函数,方便替换。

二是密钥动态化升级。v7 的种子可能固定写死在 JS 里,v8 可能改为由接口动态下发。应对方法是在请求接口时把响应里所有字段都存下来,供解密函数备用,而不是只取一个encrypt字段。很多人在逆向时习惯性忽略多余字段,当站点升级时才发现缺少必要参数,不得不重新抓包分析。

三是加入环境检测的升级。v7 档次不需要环境检测,但 v8 可能会检查navigator.webdriver或者window的某个属性。如果遇到这类升级,纯 Python 还原的难度会显著提高,这时一般会选择用 JS 运行时执行原函数。我的建议是提前做好技术储备,先用最简单的方式跑通,再逐步完善,不要为了追求“纯 Python 还原”而过度设计。

5. 项目影响范围与学习延伸

这个案例做完之后,我复盘了一下,发现它对爬虫学习路径的价值比“拿到一个链接”更大。首先是 JS 逆向的入门思维得到了完整训练,从前端代码阅读到算法翻译,再到跨语言复现,这是很多教程没有成体系讲清楚的。其次是异常排查能力得到了实打实的提升,解密失败时如何对照中间量快速定位,这种经验正是工程师日常工作中最需要的。最后是代码设计意识,把加密版本做成可插拔的模块,这个习惯直接受益于后续其他站点的爬取开发。

从我的经验看,一个“难度适中”的案例,最适合用来训练的是“调试方法论”。我不止一次看到有人拿着一个复杂加密的案例反复研究,但始终不得要领,原因就是他们跳过了中等级别案例的训练,直接挑战高难度的环境检测和混淆。如果你还处在爬虫进阶阶段,建议先把 v7 这一类练透,把“抓包 -> 定位 -> 还原 -> 落地”这四个动作内化成肌肉记忆,再往上接触更复杂的算法时会从容很多。

另外补充一个实际使用中的小技巧:解密后的播放链接尽量不要长时间缓存。影视站会在视频地址上做时效控制,有的链接几分钟就失效,有的加入防盗链后过期时间更短。我在脚本里做了一个链接缓存模块,默认缓存 5 分钟,超时后重新走一遍加密接口。这样既不会频繁请求目标站,又能保证拿到的链接始终有效。控制好请求频率,既是对目标站的礼貌,也能降低被限制的风险。

这次 v7 案例虽然已经跑通,但我知道这类站点大概率会继续升级策略。好在我已经把解密函数单独封装,即使哪天站点换上新的加密方式,我也只需要在DECRYPTORS字典中加一个新的分支,改动的代码量非常有限。这也算是我这次练习最大的实际收获。

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

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

立即咨询