前言
最近在看一些视频的时候,感觉还不错就想着下载下来,结果以前用的IDM不能使用了,一直提示涉及版权问题不支持下载,于是就想着自己写一个下载器,能实现简单的下载就行。
第一步:拿到m3u8地址,并进行解析
将m3u8地址放到浏览器下载下来,然后用记事本打卡可以看到视频是由很多ts流组成的。
通过上面的图片可以看出,m3u8文件已经包含了视频的全部信息,其中比较重要的就是红线圈起来的视频流,以及蓝线圈起来的加密方式。
先说明一下地址:流和秘钥的地址也大致分为三种,假设m3u8地址为:http://www.xxx.com/videos/234234/index.m3u8
那么
// 第一种,相当于http://www.xxx.com/videos/234234/rzko23a7.ts
#EXTINF:5.005,
rzko23a7.ts
// 第二种,相当于http://www.xxx.com/video/ts/67867/rzko23a7.ts
#EXTINF:5.005,
/video/ts/67867/rzko23a7.ts
// 第三种,就是本身
#EXTINF:10.000,
https://ccp-bj29-video-preview.oss-enet.aliyuncs.com/lt/A5FFCC02E1C25C0D3E30023510AF63B8E63DF6AA_551635824__sha1_bj29/FHD/media-0.ts?security-token=CAIS%2BgF1q6Ft5B2yf
以上了解完m3u8之后就可以开始下载了
下载视频流
1、先对m3u8解析格式化为json文件,方便后期使用
<script src="https://unpkg.com/m3u8-parser@6.0.0/dist/m3u8-parser.min.js"></script>
const url = 'http://www.xxx.com/videos/234234/index.m3u8';
const content = await (await fetch(url)).text()
const parser = new m3u8Parser.Parser()
parser.push(content)
parser.end()
const parsedManifest = parser.manifest
// 计算视频总时长
if (parsedManifest.segments) {
let duration = 0
parsedManifest.segments.forEach((segment) => {
duration += segment.duration
})
parsedManifest.duration = duration
}
console.log(parsedManifest)
2、下载视频流,并转换成Uint8Array
new Uint8Array(await (await fetch(url)).arrayBuffer())
3、如果加密就进行解密
const iv = segment.iv || 0
const key = await this.getAESKey(segment.key)
const result = new aesDecrypter.decrypt(res, key, iv)
4、将所有的ts流进行合并,并转换为blob
new Blob(this.segments.map((item) => item.byte),
{ type: ''video/MP2T }
)
5、进行下载
const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = downloadUrl
a.target = '_blank'
a.style.display = 'none'
document.body.appendChild(a)
a.download = Date.now() + ‘.ts’
a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)
大致流程就是上面的五步,下载下来的最终为一个ts视频文件。
完整代码会有一个转换为MP4的选项,但因为转换之后视频时长会出问题,不建议使用。效果如下:
完整代码
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>m3u8下载器</title>
<style>
#form {
display: block;
width: 500px;
height: 500px;
margin: 50px auto;
}
#form h2 {
text-align: center;
}
label {
display: block;
margin-bottom: 20px;
}
label span {
display: block;
font-weight: bold;
font-size: 20px;
margin-bottom: 10px;
}
label input,
label select {
width: 100%;
padding: 10px 20px;
border: 1px solid #ccc;
border-radius: 4px;
border-color: orange;
}
label button {
float: right;
padding: 10px 20px;
cursor: pointer;
}
</style>
</head>
<body>
<script src="https://unpkg.com/mux.js@6.2.0/dist/mux-mp4.min.js"></script>
<script src="https://unpkg.com/m3u8-parser@6.0.0/dist/m3u8-parser.min.js"></script>
<script src="https://unpkg.com/aes-decrypter@3.1.3/dist/aes-decrypter.min.js"></script>
<script src="./download-m3u8.js"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
let hasClick = true;
document.querySelector('#startDownload').addEventListener('click', function () {
if (!hasClick) {
return;
}
const url = document.querySelector('#url').value
const filename = document.querySelector('#filename').value
const maxRetryCount = document.querySelector('#retry').value
const maxThreadCount = document.querySelector('#thread').value
if (!url || !url.endsWith('.m3u8')) {
alert('请输入正确地址!')
return;
}
hasClick = false;
this.disabled = true;
new DownloadM3u8({
url,
filename,
maxRetryCount,
maxThreadCount,
exclude: ['www.guanggao.com', /guanggao/],
complete: () => {
hasClick = true;
this.disabled = false;
}
})
})
})
// 字符串转字节
function stringToByte (str) {
return new TextEncoder().encode(str);
}
</script>
<div id="form">
<h2>m3u8下载器</h2>
<label>
<span>地址</span>
<input id="url" type="text" placeholder="m3u8地址" /><label> </label>
<span>文件名</span>
<input id="filename" type="text" placeholder="文件名(不需要后缀,默认地址名)" />
</label>
<label>
<span>重试</span>
<select id="retry">
<option value="1" selected>1次(不重试)</option>
<option value="2">2次</option>
<option value="3">3次</option>
<option value="4">4次</option>
<option value="5">5次</option>
</select>
</label>
<label>
<span>线程</span>
<input type="range" id="thread" value="10" min="1" max="20" step="1" />
</label>
<label>
<button id="startDownload">开始下载</button>
</label>
</div>
</body>
</html>
download-m3u8.js;(function (window) {
// 下载m3u8构造函数
function DownloadM3u8(options) {
// 默认配置
const defOptions = {
// m3u8地址
url: '',
// 文件名称
filename: Date.now(),
// 失败重试次数
maxRetryCount: 3,
// 排除流,字符串或正则 数组
exclude: [],
// 输出MP4格式,(文件时间有问题,不建议使用)
outputMp4: false,
// 是否在全部文件下载完成后再进行转码
lastTranscoding: false,
// 最大线程
maxThreadCount: 10,
complete: () => {}
}
this.keys = {}
this.downloadingCount = 0
this.options = Object.assign({}, defOptions, options)
if (!this.options.url || !this.options.url.endsWith('.m3u8')) {
alert('请输入正确的视频地址!')
throw '请输入正确的视频地址!'
}
this.origin = new URL(this.options.url).origin
this.maxThreadCount = this.options.maxThreadCount
this.maxRetryCount = this.options.maxRetryCount
// 记录下载位置
this.downloadIndex = 0
this.init()
}
// 初始化
DownloadM3u8.prototype.init = async function () {
this.manifest = await this.parserM3u8()
this.segments = this.manifest.segments
this.segmentCount = this.segments.length
console.log(`共${this.segmentCount}个切片。`)
console.log('视频信息:', this.manifest)
// 没有切片
if (!this.segments || !this.segmentCount) {
return
}
// 开始执行下载操作
for (let i = 0; i < this.maxThreadCount; i++) {
this.downloadNextTs()
}
}
// 解析m3u8文件
DownloadM3u8.prototype.parserM3u8 = async function () {
const content = await (await fetch(this.options.url)).text()
const parser = new m3u8Parser.Parser()
parser.push(content)
parser.end()
const parsedManifest = parser.manifest
// 计算视频总时长
if (parsedManifest.segments) {
let duration = 0
parsedManifest.segments.forEach((segment) => {
duration += segment.duration
})
parsedManifest.duration = duration
}
return parsedManifest
}
// 下载ts文件并解密
DownloadM3u8.prototype.downloadTs = async function (segment, count = 0) {
count++
try {
const url = this.getUrl(segment.uri)
// 匹配排除流,不进行下载
if (this.options.exclude && this.options.exclude.length) {
for (let i = 0; i < this.options.exclude.length; i++) {
const item = this.options.exclude[i]
if (typeof item === 'string' && url.includes(item)) {
return Promise.resolve([])
} else if (item instanceof RegExp && item.test(url)) {
return Promise.resolve([])
}
}
}
// 对需要下载的流下载,并以Uint8Array类型返回
return new Uint8Array(await (await fetch(url)).arrayBuffer())
} catch (e) {
// 失败重试
if (count < this.maxRetryCount) {
return this.downloadTs(segment, count)
} else {
return Promise.reject()
}
}
}
// 拼接流地址
DownloadM3u8.prototype.getUrl = function (uri) {
let url
if (uri.indexOf('http') === 0) {
url = uri
} else if (uri.indexOf('/') === 0) {
url = this.origin + uri
} else {
url = this.options.url.replace(/[^\/]+$/, uri)
}
return url
}
// 按顺序下载分片ts文件
DownloadM3u8.prototype.downloadNextTs = async function () {
const index = this.downloadIndex
if (index < this.segmentCount) {
const segment = this.segments[index]
segment.status = 'downloading'
this.downloadingCount++
console.log(`第${index + 1}/${this.segmentCount}开始下载:`, segment.uri)
this.downloadTs(segment)
.then(async (res) => {
this.downloadNextTs()
segment.status = 'downloaded'
// 检查并解密ts
if (segment.key) {
res = await this.decryptTs(segment, res)
}
// 转码mp4
if (this.options.outputMp4 && !this.options.lastTranscoding) {
res = await this.conversionMp4(res, index)
}
segment.byte = res
this.downloadingCount--
this.checkDownloadComplete()
})
.catch((err) => {
console.log('下载失败:' + segment.uri, err)
this.downloadNextTs()
segment.status = 'error'
this.downloadingCount--
this.checkDownloadComplete()
})
this.downloadIndex++
}
}
// 检测全部下载并解密完成
DownloadM3u8.prototype.checkDownloadComplete = function () {
if (
this.downloadIndex === this.segmentCount &&
this.downloadingCount === 0
) {
console.log('全部流下载完成,开始下载准备')
this.mergeAndDownload()
}
}
// 获取aesKey
DownloadM3u8.prototype.getAESKey = async function (keys) {
const uri = keys.uri
if (!this.keys[uri]) {
this.keys[uri] = new Promise((resolve, reject) => {
const url = this.getUrl(uri)
fetch(url)
.then((res) => res.text())
.then(async (key) => {
// aes-js库解密方法,没有用
// var aesCbc = new aesjs.ModeOfOperation.cbc(stringToByte(key), stringToByte(iv));
// var decryptedBytes = aesCbc.decrypt(encryptedBytes);
var view = new DataView(await new Blob([key]).arrayBuffer())
const byteKey = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
view.getUint32(12)
])
resolve(byteKey)
})
.catch((err) => {
reject(err)
})
})
}
return this.keys[uri]
}
// 解密Ts
DownloadM3u8.prototype.decryptTs = async function (segment, res) {
const iv = segment.iv || 0
const key = await this.getAESKey(segment.key)
return new aesDecrypter.decrypt(res, key, iv)
}
// 合并ts
DownloadM3u8.prototype.mergeAndDownload = async function () {
// { type: 'application/octet-stream' }
const type = this.options.outputMp4
? 'video/mp4; codecs="mp4a.40.2,avc1.64001f"'
: 'video/MP2T'
const ext = this.options.outputMp4 ? 'mp4' : 'ts'
let blob
// 最后进行转码
if (this.options.outputMp4 && this.options.lastTranscoding) {
blob = new Blob(
this.segments.map((item) => item.byte),
{
type: 'video/MP2T'
}
)
const data = await this.conversionMp4(
new Uint8Array(await blob.arrayBuffer()),
0
)
blob = new Blob([data], { type })
console.log('转码完成,准备下载到本地')
} else {
blob = new Blob(
this.segments.map((item) => item.byte),
{
type
}
)
console.log('合并完成,准备下载到本地')
}
this.downloadBlob(blob, ext)
}
// 转码为mp4
DownloadM3u8.prototype.conversionMp4 = function (data, index) {
return new Promise((resolve) => {
const transmuxer = new muxjs.Transmuxer({
keepOriginalTimestamps: true,
duration: parseInt(this.manifest.duration)
})
transmuxer.on('data', (segment) => {
if (index === 0) {
let data = new Uint8Array(
segment.initSegment.byteLength + segment.data.byteLength
)
data.set(segment.initSegment, 0)
data.set(segment.data, segment.initSegment.byteLength)
resolve(data)
} else {
resolve(segment.data)
}
transmuxer.off('data')
transmuxer = null
})
transmuxer.push(data)
transmuxer.flush()
})
}
// 下载blob
DownloadM3u8.prototype.downloadBlob = function (blob, ext) {
const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = downloadUrl
a.target = '_blank'
a.style.display = 'none'
document.body.appendChild(a)
a.download = [this.options.filename || Date.now(), ext].join('.')
a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)
console.log('下载完成')
if (typeof this.options.complete === 'function') {
this.options.complete()
}
}
window.DownloadM3u8 = DownloadM3u8
})(window)
hello
5/28/2023, 3:40:07 PM