如何从0开始认识m3u8(提取,解析及下载)最近我们需要提取不同网站的m3u8资源。以前都是提取后用公司的其他部门产品进
M3U8 介绍
M3U8 文件是一种用于流媒体传输的索引文件格式,通常与 HTTP Live Streaming (HLS) 协议一起使用。
- M3U8 是一种基于文本的格式,用于存储媒体文件的元数据和位置信息。
- 它是 M3U 文件格式的一种扩展,支持 UTF-8 编码。
- M3U8 文件通常用于指示媒体播放器如何获取和播放一系列较小的媒体片段(通常是 .ts 格式的文件)。
- 这种格式常用于视频点播 (VOD) 和实时流媒体服务。
m3u8文件的基本内容大致如下
#EXTM3U // 文件头部,表示这是一个 M3U 文件
#EXT-X-VERSION:3 // 指定 HLS 协议版本
#EXT-X-TARGETDURATION:10 // 指定每个媒体片段的最大持续时间。单位是s
#EXT-X-KEY:METHOD=AES-128,URI="" // 若 ts 视频片段进行了加密,则需配置该字段指定加密解析方式。例如该字段指定了加密算法为 AES-128,密钥通过请求 URI 获取,以用于解密后续解析 ts 文件。
#EXT-X-MEDIA-SEQUENCE:0 // 指定媒体序列的第一个片段的编号。对于视频点播资源该字段一般是 0,但是在直播场景下,这个序列号标识直播段的起始位置。
#EXTINF:10.0, // 描述接下来的媒体片段的持续时间 每个 #EXTINF 行后面跟着一个 URI,指向实际的媒体片段文件(.ts格式文件)
https://global.tfboyaad.com/s10/ppot/_definst_/mp4:s13/jvod/lxj-sclm-02-00DF1ACB1.mp4/media_0.ts?vendtime=253402297200&vhash=MTVVcIX0Y1W-I_TVatjTVE_Ll5DKfP87frA1jCBUDts=&vCustomParameter=0_0.0.0.0.GL&lb=545fd455403ede15d45b435947e31900&pub=26&vv=1
#EXTINF:10.0,
https://global.tfboyaae.com/s10/ppot/_definst_/mp4:s13/jvod/lxj-sclm-02-00DF1ACB1.mp4/media_1.ts?vendtime=253402297200&vhash=MTVVcIX0Y1W-I_TVatjTVE_Ll5DKfP87frA1jCBUDts=&vCustomParameter=0_0.0.0.0.GL&lb=545fd455403ede15d45b435947e31900&pub=26&vv=1
#EXTINF:7.216,
https://global.etcbbb.xyz/s10/ppot/_definst_/mp4:s13/jvod/lxj-sclm-02-00DF1ACB1.mp4/media_288.ts?vendtime=253402297200&vhash=MTVVcIX0Y1W-I_TVatjTVE_Ll5DKfP87frA1jCBUDts=&vCustomParameter=0_0.0.0.0.GL&lb=545fd455403ede15d45b435947e31900&pub=26&vv=1
#EXT-X-ENDLIST // M3U8 文件结束符
使用场景
- 视频点播 (VOD): 在线视频网站上的电影或剧集。
- 实时流媒体: 直播电视节目、体育赛事、新闻广播等。
- 多码率适应: 提供不同比特率的流,以适应不同的网络条件。
我们知道m3u8链接保存的是若干媒体片段连接的文本文件,我们需要获取到全部的媒体(.ts)后将其合并就是一个完整的视频文件了。
如何提取网站m3u8资源
对于浏览器插件来说,我们可以使用请求相关api很容易获取到对应的请求,对其进行分析提取就可以准确拿到资源。
webRequest API
关于webrequest API
chrome.webRequest | API | Chrome for Developers可以看这里。
webRequest API 参数
- 参数一:回调。回调参数根据api和opt_extraInfoSpec的不同参数对象的属性也不同。
- 参数二:请求过滤对象
urls
监听指定某些域的请求。一般都是全部监听设置为["<all_urls>"]
types
监听指定的某些资源类型的请求。一般不设置。tabId
监听指定的某个页面的请求。一般不设置。windowId
监听指定的某个浏览器窗口的所有tab页请求。一般不设置。
- 参数三:opt_extraInfoSpec数组。不同的webRequest API可传递的配置参数不同。并且一些配置还需要在
manifest.json
中进行权限声明。
下面我们来总结一下不同api所具有的opt_extraInfoSpec
。
onAuthRequired
: 收到身份验证失败时触发。"blocking"
使请求同步,以便您可以取消或重定向请求。- "asyncBlocking" 指定以异步方式处理回调函数。
- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
"responseHeaders" 指定应包含在事件中的响应标头。
onBeforeRedirect
:在即将发生服务器发起的重定向时触发。- "responseHeaders" 指定应包含在事件中的响应标头。
- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
onBeforeRequest
:在即将发出请求时触发。"blocking"
:使请求同步,以便您可以取消或重定向请求。"requestBody"
:指定应在事件中包含请求正文。- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
onBeforeSendHeaders
:在请求头可用后,在发送 HTTP 请求之前触发。这种情况可能会在 TCP 连接到服务器之后、发送任何 HTTP 数据之前发生。- "requestHeaders" 指定应在事件中包含请求标头。
"blocking"
使请求同步,以便您可以取消或重定向请求。- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
onCompleted
:完成请求时触发。- "responseHeaders" 指定应包含在事件中的响应标头。
- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
onErrorOccurred
:发生错误时触发。- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
onHeadersReceived
:在收到请求的 HTTP 响应头时触发。"blocking"
:使请求同步,以便您可以取消或重定向请求。- "responseHeaders" 指定应包含在事件中的响应标头。
- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
onResponseStarted
:在收到响应正文的第一个字节时触发。对于 HTTP 请求,这意味着可以使用状态行和响应头。- "responseHeaders" 指定应包含在事件中的响应标头。
- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
onSendHeaders
:在请求即将发送到服务器之前触发(在触发 onSendHeaders 时,可以看到对以前的 onBeforeSendHeaders 回调的修改)。- "requestHeaders" 指定应在事件中包含请求标头。
- "extraHeaders" 指定标头可以违反跨域资源共享 (CORS)。
如果设置blocking, 我们还需要再manifest中配置webRequestBlocking权限。然后我们可以手动的设置回调的返回值,具体可以查看这里
了解了上面的api后我么来看看如何进行m3u8资源的拦截即分析。
拦截提取m3u8资源
监听请求,保存请求头信息。
onSendHeaders
保存请求的请求头信息。(提取referer, origin, cookie字段)onResponseStarted
获取响应信息,并将当前请求头保存到响应对象中。onErrorOccurred
请求出错,删除保存的请求头,节约空间。
// 保存requestHeaders 获取请求头
chrome.webRequest.onSendHeaders.addListener(
function (data) {
const requestHeaders = getRequestHeaders(data);
// 保存请求头到全局map对象中
requestHeaders && G.requestHeaders.set(data.requestId, requestHeaders);
}, { urls: ["<all_urls>"] }, ['requestHeaders',
chrome.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS].filter(Boolean) // 指定请求头
);
// onResponseStarted 浏览器接收到第一个字节触发,保证有更多信息判断资源类型(比如保存请求头到请求对象中)
chrome.webRequest.onResponseStarted.addListener(
function (data) {
try {
// 获取请求头,并更新到资源对象中
const requestHeaders = G.requestHeaders.get(data.requestId);
if (requestHeaders) {
data.requestHeaders = requestHeaders;
G.requestHeaders.delete(data.requestId);
}
findMedia(data);
} catch (e) { console.log(e, data); }
}, { urls: ["<all_urls>"] }, ["responseHeaders"]
);
// 删除失败的requestHeadersData
chrome.webRequest.onErrorOccurred.addListener(
function (data) {
G.requestHeaders.delete(data.requestId);
}, { urls: ["<all_urls>"] }
);
分析请求, 提取m3u8资源
- 主要就是分析webReuqest api拦截的对象,然后通过下面指定的后缀和类型进行匹配保存到本地。
// 后缀
Ext: [
{ "ext": "m3u8", "size": 0, "state": true },
{ "ext": "m3u", "size": 0, "state": true },
{ "ext": "mpeg", "size": 0, "state": true },
],
// 响应类型content-type
Type: [
{ "type": "application/vnd.apple.mpegurl", "size": 0, "state": true }, // m3u8
{ "type": "application/x-mpegurl", "size": 0, "state": true }, // m3u8
{ "type": "application/mpegurl", "size": 0, "state": true },
{ "type": "application/octet-stream-m3u8", "size": 0, "state": true },
],
function findMedia(data, isRegex = false, filter = false, timer = false) {
data.getTime = Date.now();
// 屏蔽特殊页面发起的资源 (url 不为http, https, blob的请求,initiator表示当前请求属于哪个referer。)
if (data.initiator != "null" &&
data.initiator != undefined &&
isSpecialPage(data.initiator)) { return; }
// 屏蔽特殊页面的资源 (屏蔽当前资源非http, https,blob 的。)
if (isSpecialPage(data.url)) { return; }
const urlParsing = new URL(data.url);
// 获取文件名和后缀
let [name, ext] = fileNameParse(urlParsing.pathname);
// 非正则匹配
if (!isRegex) {
// 获取头部信息 (TODO: size, type, attachment) 响应头信息
data.header = getResponseHeadersValue(data);
// 检查后缀
if (!filter && ext != undefined) { // 文件名.m3u8
filter = CheckExtension(ext, data.header?.size); // TODO: 有查找到后缀就return true
if (filter == "break") { return; } // ts, 大小return break
}
//检查类型 G.Type 中的类型全部都不会过滤掉。(TODO: 如果无后缀,我们进行content-type类型检测。)
if (!filter && data.header?.type != undefined) {
filter = CheckType(data.header.type, data.header?.size);
if (filter == "break") { return; }
}
//查找附件
if (!filter && data.header?.attachment != undefined) {
const res = data.header.attachment.match(reFilename);
if (res && res[1]) {
[name, ext] = fileNameParse(decodeURIComponent(res[1]));
filter = CheckExtension(ext, 0);
if (filter == "break") { return; }
}
}
}
if (!filter) { return; }
// 谜之原因 获取得资源 tabId可能为 -1 firefox中则正常
// 检查是 -1 使用当前激活标签得tabID
data.tabId = data.tabId == -1 ? G.tabId : data.tabId;
// 最后还是用标签的tabID进行搜集m3u8信息的。
cacheData[data.tabId] ??= [];
cacheData[G.tabId] ??= [];
// 查重 避免CPU占用 大于500 强制关闭查重(TODO: 资源去重,url进行去重。)
if (G.checkDuplicates && cacheData[data.tabId].length <= 500) {
for (let item of cacheData[data.tabId]) {
if (item.url.length == data.url.length &&
item.cacheURL.pathname == urlParsing.pathname &&
item.cacheURL.host == urlParsing.host &&
item.cacheURL.search == urlParsing.search) { return; }
}
}
// TODO: 获取当前活跃标签页页面信息(并将视频音频信息保存到本地存储。)
chrome.tabs.get(data.tabId, async function (webInfo) {
if (chrome.runtime.lastError) { return; }
// requestHeaders 中cookie 单独列出来
if (data.requestHeaders?.cookie) {
data.cookie = data.requestHeaders.cookie;
data.requestHeaders.cookie = undefined;
}
const info = {
name: name,
url: data.url,
size: data.header?.size,
ext: ext,
type: data.mime ?? data.header?.type,
tabId: data.tabId,
isRegex: isRegex,
requestId: data.requestId ?? Date.now().toString(),
extraExt: data.extraExt,
initiator: data.initiator,
requestHeaders: data.requestHeaders,
cookie: data.cookie,
cacheURL: { host: urlParsing.host, search: urlParsing.search, pathname: urlParsing.pathname },
getTime: data.getTime
};
// 不存在 initiator 和 referer 使用web url代替initiator
if (info.initiator == undefined || info.initiator == "null") {
info.initiator = info.requestHeaders?.referer ?? webInfo?.url;
}
// 装载页面信息
info.title = webInfo?.title ?? "NULL";
info.favIconUrl = webInfo?.favIconUrl;
info.webUrl = webInfo?.url;
// 储存数据
cacheData[info.tabId] ??= [];
// TODO: 将数据存储到全局,并更新到本地永久存储。
cacheData[info.tabId].push(info);
// 当前标签媒体数量大于100 开启防抖 等待5秒储存 或 积累10个资源储存一次。
if (cacheData[info.tabId].length >= 100 && debounceCount <= 10) {
debounceCount++;
clearTimeout(debounce);
debounce = setTimeout(function () { save(info.tabId); }, 5000);
return;
}
// 时间间隔小于500毫秒 等待2秒储存
if (Date.now() - debounceTime <= 500) {
clearTimeout(debounce);
debounceTime = Date.now();
debounce = setTimeout(function () { save(info.tabId); }, 2000);
return;
}
console.log("cacheDatacacheDatacacheData", cacheData)
save(info.tabId);
});
}
function save(tabId) {
console.error("保存资源", cacheData)
clearTimeout(debounce);
debounceTime = Date.now();
debounceCount = 0;
// 保存数据到本地
(chrome.storage.session ?? chrome.storage.local).set({ MediaData: cacheData }, function () {
chrome.runtime.lastError && console.log(chrome.runtime.lastError);
});
}
这样我们就拿到了当前页面提取的m3u8资源信息了。获取到信息我们就可以对其进行分析, 预览及下载了。
对m3u8预览并进行解析视频参数
通过webRequest api进行请求分析,我们拿到了当前页面所有m3u8相关资源信息,我们可以通过hls
库对m3u8进行解析预览。
如果我们在请求时出现错误,我们可以使用updateSessionRules
来修改请求头,chrome.declarativeNetRequest.updateSessionRules
此 API 使扩展可以指定描述如何处理网络请求的条件和操作。这些声明性规则使浏览器能够评估和修改网络请求,而无需通知扩展有关单个网络请求。需要在扩展中配置"declarativeNetRequest"
或 "declarativeNetRequestWithHostAccess"
权限。感兴趣的可以去研究下这个api,它和webRequest api 都可以对请求进行更改。
// 修改请求头
function setRequestHeaders(data = {}, callback = undefined) {
chrome.tabs.getCurrent(function (tabs) {
const rules = { removeRuleIds: [tabs ? tabs.id : 1] };
if (Object.keys(data).length) {
// 修改扩展程序的当前会话级范围规则集。(TODO: 资源请求头的限制,我们每次请求携带对应的请求头)
const requestHeaders = Object.keys(data).map(key => ({ header: key, operation: "set", value: data[key] }));
rules.addRules = [{
// 唯一标识规则的 ID。
"id": tabs ? tabs.id : 1,
// 规则匹配时执行的操作
"action": { // 屏蔽网络请求,重定向,修改请求头/响应头
// 要执行的操作类型。(修改网络请求中的请求/响应标头。)
"type": "modifyHeaders",
"requestHeaders": requestHeaders
},
// 触发此规则的条件。
"condition": {
"resourceTypes": ["xmlhttprequest", "media", "image"]
}
}];
if (tabs) {
rules.addRules[0].condition.tabIds = [tabs.id];
}
}
// console.log(rules);
// 修改扩展程序的当前会话级范围规则集
chrome.declarativeNetRequest.updateSessionRules(rules, function () {
callback && callback();
});
});
}
获取m3u8资源时长和分辨率的两种方式。
- 通过监听hls的
Hls.Events.BUFFER_CREATED, Hls.Events.LEVEL_LOADED
事件。
// 监听 LEVEL_LOADED 所有切片载入完成
hls.on(Hls.Events.LEVEL_LOADED, async function (event, data) {
console.log("所有切片载入完成", data);
// data.details.totalduration 视频总时长
hls.attachMedia(video); // 绑定video
});
// 监听 BUFFER_CREATED 获得第一个切片数据 获取视频分辨率(这个需要绑定video才可以拿到触发)
hls.on(Hls.Events.BUFFER_CREATED, function (event, data) {
console.log("获得第一个切片数据", data);
// data.tracks.video.metadata.width , height
});
- 通过监听video的
loadedmetadata
事件。
video.addEventListener("loadedmetadata", function () {
if (this.duration && this.duration != Infinity) {
data.duration = this.duration;
}
if (this.videoHeight && this.videoWidth) {
data.videoWidth = this.videoWidth;
data.videoHeight = this.videoHeight;
}
});
这两个事件的监听都需要进行m3u8资源加载解析后对资源进行video绑定。
通过上面的分析,我们就不难发现其实预览m3u8资源很简单,就是ton过hls进行资源加载,然后将其绑定到video标签上即可。
<script src="./hls.js"></script>
<video id="video" controls></video>
<script>
if(Hls.isSupported()) {
// var video = document.createElement('video');
var video = document.getElementById("video");
var hls = new Hls();
hls.loadSource("https://XXXX/index.m3u8");
hls.attachMedia(video);
// 监听 MANIFEST_PARSED m3u8解析完成
hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
console.log("hls", hls.bufferController._objectUrl)
console.log("m3u8解析完成", data);
});
// 监听 LEVEL_LOADED 所有切片载入完成
hls.on(Hls.Events.LEVEL_LOADED, async function (event, data) {
console.log("所有切片载入完成", data, data.details.totalduration);
});
// 监听 ERROR m3u8解析错误
hls.on(Hls.Events.ERROR, function (event, data) {
console.log(data.error);
});
// 监听 BUFFER_CREATED 获得第一个切片数据
// TODO: 获取视频分辨率(这个需要绑定video才可以拿到触发)
hls.on(Hls.Events.BUFFER_CREATED, function (event, data) {
console.log("获得第一个切片数据", data, data.tracks.video.metadata.width, data.tracks.video.metadata.height);
});
// 秒转换成时间
function secToTime(sec) {
let hour = (sec / 3600) | 0;
let min = ((sec % 3600) / 60) | 0;
sec = (sec % 60) | 0;
let time = hour > 0 ? hour + ":" : "";
time += min.toString().padStart(2, '0') + ":";
time += sec.toString().padStart(2, '0');
return time;
}
video.addEventListener("loadedmetadata", function () {
console.log("video中获取总时间和分辨率", video.duration, secToTime(video.duration), video.videoWidth, video.videoHeight)
});
}
</script>
解析并合并下载成mp4
对于m3u8资源下载有很多方式,网上也有很多在线的下载器。分析cat-catch插件发现,主要是通过ffmpeg
, m3u8dl
, mux.js
。
m3u8dl
他是一个本地命令行工具,我们通过设置好的命令行参数输入即可完成一系列的操作。
"https://valipl.cp31.ott.cibntv.net/6572CFC4FC84B721CB3823FDF/030006000066A312A6E458C7ACA6BA06E65824-B736-434B-B1DC-626B5721C062.m3u8?ccode=0502&duration=300&expire=18000&psid=06bc46758c18b9e79d2050433b52ce8f41346&ups_client_netip=725d98eb&ups_ts=1723884077&ups_userid=&utid=jkdHH51ID3cCAXJdmOvhhjbW&vid=XNjQwODk3OTI0OA%3D%3D&vkey=B68bba8e21a4b76e2dda0c5509cdf3756&s=daeb949bb1fa47c7b45f&eo=1&t=74a32ba8c36e9fe&cug=1&fms=d58d7e59606464d1&tr=300&le=570affd94c2d7174581638f4e6d1c8ae&ckt=5&m_onoff=0&rid=200000000039849437C0B48046CC6C0BB0B90C5B02000000&type=mp4hdv3&bc=2&dre=u37&si=73&dst=1&sm=1&operate_type=1&hotvt=1&app_key=24679788&app_ver=9.4.97" --workDir "%USERPROFILE%\Downloads\m3u8dl" --saveName "小猪佩奇 第十季 公牛先生要结婚啦-少儿-高清完整正版视频在线观看-优酷_1723884086483" --enableDelAfterDone
async function mergeTs(startTime) {
// _tsBuffer 下载到缓存中的切片arraybuffer
// 默认下载格式
let fileBlob = new Blob(_tsBuffer, { type: "video/MP2T" });
// 获取片段扩展名
let ext = _fragments[0].url.split("/").pop();
ext = ext.split("?")[0];
ext = ext.split(".").pop();
ext = ext ? ext : "ts";
// 转码mp4
if (ext.toLowerCase() != "mp4") {
let index;
transmuxerheadEncode = false;
/* 转码工具 */
// remux是否视频音频混合
transmuxer = new muxjs.mp4.Transmuxer({ remux: true }); // mux.js 对象
// 转码服务监听
transmuxer.on('data', function (segment) {
// console.log(segment);
// 头部信息
if (!transmuxerheadEncode) {
transmuxerheadEncode = true;
let data = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength);
data.set(segment.initSegment, 0);
data.set(segment.data, segment.initSegment.byteLength);
_tsBuffer[index] = fixFileDuration(data, downDuration);
return;
}
_tsBuffer[index] = segment.data;
});
// 载入ts数据转码
for (index in _tsBuffer) {
transmuxer.push(new Uint8Array(_tsBuffer[index]));
transmuxer.flush();
}
// 关闭监听
transmuxer.off('data');
// 正确转换 下载格式改为 mp4
if (transmuxerheadEncode) {
fileBlob = new Blob(_tsBuffer, { type: "video/mp4" });
ext = "mp4";
console.log("合并下载后的链接", URL.createObjectURL(fileBlob))
}
transmuxer = undefined;
transmuxerheadEncode = undefined;
}
chrome.downloads.download({
url: URL.createObjectURL(fileBlob),
filename: `merged_video.mp4`,
}, function (downloadId) { console.log("下载完成", downloadId) });
console.log("===============下载完成花费时间", new Date().getTime() - startTime)
_tsBuffer.splice(0); delete _tsBuffer;
}
参考
往期年度总结
往期文章
专栏文章
以上就是自己对cat-catch插件研究的一些分享,如果对你有帮助,🔥如果此文对你有帮助的话,欢迎💗关注、👍点赞、⭐收藏、✍️评论, 支持一下博主~
公众号:全栈追逐者,不定期的更新内容,关注不错过哦!
转载自:https://juejin.cn/post/7403576996394467364