python全自动爬取m3u8网页视频(各类网站都通用)

当前人工智能,大语言模型的火热,使得python这门编程语言的使用越来越广泛。最近也开始学习了python,发现它在自动化方面的确有得天独厚的优势。python的简单易用,丰富的开源库,完善的生态,使得它有可能成为大语言模型和物理世界连接的桥梁。就像人们使用linux shell操作linux系统,大模型可以使用python来执行它想要执行的能影响物理世界的指令。

学习了python之后,发现可以做的事情很多,让我们操作计算机,数据的获取和处理变得简单了许多,特别是网络中的数据。我们可以使用python来做许多自动化的操作。为了熟悉使用这门语言,也开始试着写一些简单的爬虫,这些所见即所得的成果比起单纯研究技术和算法有趣的多。当然爬虫也是一门技术。下面就是分享一些视频爬取经验,仅供记录经验,技术分享交流使用。

1、M3U8介绍

M3U8 是一种基于文本的播放列表文件格式,主要用于 ​​HTTP Live Streaming(HLS)流媒体协议​​,由苹果公司开发并广泛应用于在线视频和音频传输中。

M3U8它实际上就是用一个文本文件(一般为.m3u8后缀)来定义视频和音频等流媒体的播放行为。我们在浏览器上看视频时,当一个完整的视频文件很大时,比如一个G,如果等网页把这1G的视频文件全部下载下来再播放显然很不现实。那么直接的解决方式就是把这1G的视频文件分成很多个一小段的视频文件(比如1个小视频文件能播放1分钟),边下边播,就不会让用户长时间的等待下载才能播放。M3U8文件就是定义这一个个小视频文件的。M3U8文件的格式类似如下:

XML 复制代码
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-KEY:METHOD=AES-128,URI="https://aa.bb.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/crypt.key?auth_key=1742691847-72-0-d8787a27fe2eef60b071ac643f875c1b",IV=0xba25433fa8984b5abb5e0f5cc41f1a60
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:5
#EXTINF:5.000,
https://aa.bb.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/0f07ef21ec282c96ac32bb73d221f90c0.ts?auth_key=1742691847-72-0-0b996b02189a9b159cdca1b30a72bc39
#EXTINF:5.000,
https://aa.bb.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/0f07ef21ec282c96ac32bb73d221f90c1.ts?auth_key=1742691847-72-0-bf3bd2faf0a60b57bf33a620c16533db

比如文件中这样的一行:https://aa.bb.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/0f07ef21ec282c96ac32bb73d221f90c0.ts?auth_key=1742691847-72-0-0b996b02189a9b159cdca1b30a72bc39

就是一个单独的小视频文件的下载地址。我们一般只要拿到这个下载地址,就可以使用模拟浏览器请求获取到文件数据。当然如果视频数据需要保密,那么返回的数据就是加密后的视频数据,我们要解密后才能播放。

那我怎么知道视频数据有没有加密?用什么加密方式?怎么解密?答案都在M3U8文件里。文件内容中METHOD=AES-128就是写明了加密方式,加密key的获取通过这个url去获取,URI="https://aa.bb.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/crypt.key?auth_key=1742691847-72-0-d8787a27fe2eef60b071ac643f875c1b",还有IV向量为IV=0xba25433fa8984b5abb5e0f5cc41f1a60,有了这些信息我们就可以对文件数据进行解密了,AES加密模式一般都是用CBC模式,这里没有明确写出。如果没有加密我们下载下来可以直接用本地视频播放器播放了。

上面的M3U8文件示例,只是一般形式,还有一些M3U8的文件中的ts文件只有一个ts文件名,并不是完整的URL下载链接,比如只有"12345.ts",此时我们需要把地址拼接完整,前面的地址就是用M3U8文件下载地址来拼接,比如M3U8文件的下载URL为:https://aa.b.com/video91/m3u8/2024/07/02/a6ea9246/index.m3u8,那么ts的下载地址拼接完后就是:https://aa.b.com/12345.ts

2、获取M3U8文件

从上面的介绍中,我们知道只要获取到M3U8文件,基本上就能下载到视频文件。怎么获取呢?一般手动获取就是用我们浏览器的F12打开调试控制台,搜索网络请求,直接查找请求URL中包含关键字".m3u8"的请求,然后查看响应,复制出来就可以。如果手动查找,可以参考:Python爬取下载m3u8视频,原来这么简单!-CSDN博客

但是不是每个小白都能那么顺利的找到M3U8文件,我们想要的就是全自动下载,python就是干自动化的工作的。我们能不能做成一个通用的视频下载脚本,自动获取分析网页,自动获取M3U8文件,自动下载ts视频文件,自动合并视频文件呢。当前可以的,python的开源库playwright,Selenium​等都是自动化模拟浏览器的。我们为了能做一个通用的m3u8视频抓取脚本,就不用一个个网站的去分析请求和逆向js。我们使用最简单的方式,就是启动一个浏览器去模拟浏览器访问包含m3u8视频的网页,然后监控网络请求,捕获到返回的m3u8文件就可以了。

3、解析M3U8文件

只要了解了M3U8文件内容的构成,很容易解析出加密方式和获取ts文件下载链接

4、下载ts文件

ts文件的下载,可以使用requests库直接请求,有的网站有一些反爬措施,需要加Referer和User-Agent请求头。可以使用requests+多线程池并行下载多个ts,下载的很快,取决于你的网速,当然要考虑网站的承受能力也不用开太多线程。也可以使用aiohttp+协程并行下载

5、ts文件数据解密

前面提到ts文件数据可能被加密了,我们可以直接根据M3U8的内容获取到所有解密需要用的参数,一般目前都是用AES-128,CBC模式进行加密,还有的都是不加密的,不加密可以省略这个步骤

6、ts视频流合并

ts视频文件是一个个小的视频文件,能单独播放,但是我们要合并成一整个视频文件才好用我们的本地播放器连续播放,此时就需要借助ffmpeg这个工具进行合并,这是一个独立的可执行程序,通过命令行合并。如果没有安装需要安装

7、python m3u8视频爬虫代码

以下爬虫代码可以处理不加密的和AES-128 CBC加密的ts文件,目前我发现的视频网站都是这两种加密方式,如果有其他的加密方式可以很容易自行扩展。

脚本支持下载当前页面所有的m3u8视频,视频文件名称从网页标题中提取,多个视频按xxx_1.mp4,xxx_2.mp4命名

(1)需要安装的python库

playwright(模拟浏览器) requests(http请求) pycryptodome(AES解密)

python版本要python3

(2)需要安装的工具

ffmpeg(用于ts视频合并)

mac安装:

bash 复制代码
brew install ffmpeg

Ubuntu/Debian安装:

bash 复制代码
sudo apt update  
sudo apt install ffmpeg

安装完测试一下:

bash 复制代码
mypc$ ffmpeg -h
ffmpeg version 7.1.1 Copyright (c) 2000-2025 the FFmpeg developers
  built with Apple clang version 16.0.0 (clang-1600.0.26.6)
...

(3) 执行

需要给main.py函数加可执行权限:chmod +x main.py

bash 复制代码
./main.py https://aa.bb.com/1.html

或者

bash 复制代码
python main.py https://aa.bb.com/1.html

(4)代码

第一个文件是并发下载ts文件的模块parallel_download_m3u8.py

python 复制代码
from collections import namedtuple
import os
from enum import Enum
import threading
import requests
import logging
from concurrent.futures import ThreadPoolExecutor
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import os
import time
import common
# 定义m3u8的加密类型
class M3u8EncryptType(Enum):
    UNKNOWN = 0
    PLAIN = 1
    AES128 = 2
# 定义多线下载函数
def download_thread_fun(url:str, referrer_url:str, ts_name:str, save_dir:str, status_dict: dict[str, bool], lock: threading.Lock)->None:
    r"""多线程下载函数

    :param url: ts文件下载链接
    :param referrer_url: 请求头部中的referer
    :param ts_name: ts文件名
    :param save_dir: 保存目录
    :param status_dict: 下载状态字典
    :param lock: 线程锁
    :return: None
    """

    req_times = 1
    while req_times <= 10:
        try:
            # 开始下载
            logging.info(f"request({req_times}):{url}")
            req_times += 1
            # 访问链接,获取ts文件
            headers = {'Referer': referrer_url, "User-Agent": common.USER_AGENT}
            rsp = requests.get(url, headers=headers, timeout=10)
            if rsp.status_code != 200:
                logging.error(f"获取ts文件失败,ts_name:{ts_name} url:{url} status_code:{rsp.status_code} text:{rsp.text}")
                continue

            #把响应内容写入文件
            with open(os.path.join(save_dir, ts_name), 'wb') as f:
                f.write(rsp.content)
            # 下载完成,修改状态字典
            with lock:
                status_dict[ts_name] = True
            logging.info(f"download finish, ts_name: {ts_name}")
            # 处理完成,跳出循环    
            break
        except requests.Timeout as timeout_error:
            logging.error(f"连接超时:{timeout_error}")
        # 其他异常
        except Exception as e:
            logging.error(f"othererror:{e}")

def download_m3u8_ts(ts_url_list: list[str], referer_url: str, output_dir: str, ts_name_list: list[str])->bool:
    r"""多线程下载m3u8的分片文件

    :param ts_url_list: ts文件下载链接列表
    :param referer_url: 请求头部中的referer
    :param output_dir: 保存目录
    :param ts_name_list: ts文件名列表,输出参数,后续按这个顺序合并ts文件
    :return: bool,True: 下载成功, False: 下载失败
    """
    curr_ts_file_list = []
    # 创建保存目录
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)
    else:
        # 获取目录下的所有ts文件名称,只保留文件名称,去掉路径
        curr_ts_file_list = os.listdir(output_dir)
    
    # list转换成set
    curr_ts_file_set = set(curr_ts_file_list)

    # 下载状态字典
    status_dict = {}
    lock = threading.Lock()
    # 使用线程池下载ts文件
    with ThreadPoolExecutor(max_workers=100) as executor:
        for ts_url in ts_url_list:
            # url类似:https://aa.b.com/videos/638c59a97f4443188948172f17656210/si/c_qtQ_AoVUPdMt8Ok9AyhzH9IvyNbpsCrFBX8Hq-PohdL8k_jBQ.ts?mm=OWFkMWZkOTM0NjYxZGZmZmYzOTJkMzg3MmNmMTUyZWM4OTE1MDcyMDM5ZGIxZjcz&t=1743165232620&d=rfjyb3bu5j0r.com&e=1743186832620&ip=240e:47e:3460:347b:878:2364:a97c:49e&gap=c9b8c4a5827b11e7fea3a276f8d3e70f&ic=CN&slot=2cd0dcc81c53a97a3aaebf2583296138
            #提取出ts文件名
            ts_name = ts_url.split('/')[-1]
            ts_name = ts_name.split('?')[0]
            logging.info(f"parse ts_name:{ts_name}")

            if ts_name == '':
                logging.error(f'ts_name is empty, url:{ts_url}')
                continue
            ts_name_list.append(ts_name)
            # 下载状态字典中添加ts文件名
            if ts_name in curr_ts_file_set:
                logging.info(f"ts_file: {ts_name} already exists, skip download")
                status_dict[ts_name] = True
                continue
            else:
                status_dict[ts_name] = False
                # 任务提交到线程池
                executor.submit(download_thread_fun, ts_url, referer_url, ts_name, output_dir, status_dict, lock)
    
    # 检查下载状态字典
    all_download_finish = True
    # logging.info(f"download status_dict:{status_dict}")   
    for ts_name, is_download in status_dict.items():
        if not is_download:
            logging.error(f"ts_name: {ts_name} download failed")
            all_download_finish = False
    
    return all_download_finish

# 定义获取IV向量的函数
def parse_iv(txt: str)->str:
    """解析IV向量

    :param txt: m3u8文件内容
    :return: IV向量字符串
    """
    # 先判断是不是文本类型
    if not isinstance(txt, str):
        logging.error("输入参数不是字符串")
        return ""
    
    index = txt.find("IV=")
    if -1 == index:
        logging.error("未找到IV向量")
        return ""
    # 查找\n位置
    end_index = txt.find("\n", index)
    if -1 == end_index:
        logging.error("未找到IV向量结束位置")
        return ""
    # 取出IV向量
    iv = txt[index+3:end_index]
 
    # 去掉前面的0x
    iv = iv.removeprefix("0x")
    logging.info(f"iv:{iv}")
    return iv

# 定义获取加密秘钥URI的函数
def parse_key_uri(txt):
    # 先判断是不是文本类型
    if not isinstance(txt, str):
        logging.error("输入参数不是字符串")
        return ""

    '''
    value的格式如下:
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-KEY:METHOD=AES-128,URI="https://aa.b.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/crypt.key?auth_key=1742912656-80-0-e70e0b290d6dba2ddde376dc9dbdb22c",IV=0xba25433fa8984b5abb5e0f5cc41f1a60
    #EXT-X-MEDIA-SEQUENCE:0
    #EXT-X-TARGETDURATION:5
    #EXTINF:5.000,
    # '''
    # 先从value中获取到加密密钥的URI:https://aa.b.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/crypt.key?auth_key=1742912656-80-0-e70e0b290d6dba2ddde376dc9dbdb22c
    uri_start = "URI=\""
    index = txt.find(uri_start)
    if -1 == index:
        logging.error("未找到加密密钥的URI")
        return ""

    # 继续找下一个双引号的位置
    index += len(uri_start)
    end_index = txt.find("\"", index)
    if -1 == end_index:
        logging.error("未找到加密密钥的URI结束位置")
        return ""
    
    # 取出加密密钥的URI
    uri = txt[index:end_index]
    return uri

#定义返回结构
ProcResult = namedtuple("ProcResult", "enc_method key_url iv ts_url_list", defaults=[M3u8EncryptType.UNKNOWN, None, None, None])

def parse_m3u8_file(m3u8_req_url: str, m3u8_file_content: str):
    r"""解析m3u8文件内容

    :param m3u8_file_content: m3u8文件内容
    :return: 返回一个命名元祖,包括加密方式method, 秘钥获取url key_url, ts文件下载链接列表 ts_url_list
    """
    # 截取m3u8请求Url的资源路径,比如:https://aa.b.com/video91/m3u8/2024/07/02/a6ea9246/index.m3u8
    index = m3u8_req_url.rfind("/")
    url_prefix = m3u8_req_url[:index+1]
    logging.info(f"url_prefix:{url_prefix}")

    # 初始化变量,避免未定义
    key_url = None
    iv = None

    # 如果是AES128加密,会有这行:#EXT-X-KEY:METHOD=AES-128,URI="https://aa.b.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/crypt.key?auth_key=1742691847-72-0-d8787a27fe2eef60b071ac643f875c1b",IV=0xba25433fa8984b5abb5e0f5cc41f1a60
    index = m3u8_file_content.find("#EXT-X-KEY:METHOD=")
    enc_method = M3u8EncryptType.UNKNOWN
    if -1 == index:
        enc_method = M3u8EncryptType.PLAIN
    else:
        # 截取出加密方式
        start_index = index + len("#EXT-X-KEY:METHOD=")
        newline_index = m3u8_file_content.find("\n", start_index)
        end_index = m3u8_file_content.find(",", start_index)
        if newline_index < end_index:
            end_index = newline_index
        enc_method_str = m3u8_file_content[start_index:end_index]
        enc_method_str = enc_method_str.strip()

        if enc_method_str == "AES-128":
            # 截取出秘钥获取url
            key_url = parse_key_uri(m3u8_file_content)
            # 截取出IV向量
            iv = parse_iv(m3u8_file_content)
            enc_method = M3u8EncryptType.AES128
            if key_url == "" or iv == "":
                logging.error(f"AES128加密,但未找到秘钥获取url或IV向量, key_url:{key_url}, iv:{iv}")
                # 抛出异常
                raise Exception("AES128加密,但未找到秘钥获取url或IV向量")
            if not key_url.startswith("http"):
                key_url = url_prefix + key_url
            logging.info(f"AES128加密, key_url:{key_url}, iv:{iv}")
        elif enc_method_str == "NONE":
            enc_method = M3u8EncryptType.PLAIN
        else :
            logging.error(f"不支持的加密方式:{enc_method_str}")
            # 抛出异常
            raise Exception(f"不支持的加密方式:{enc_method_str}")

    lines = m3u8_file_content.split("\n")
    def change_ts_url(line):
        if line.startswith("http"):
            return line
        else:
            return url_prefix + line
        
    ts_url_list = [change_ts_url(item) for item in lines if not item.startswith("#") and not item.strip() == ""]

    return ProcResult(enc_method, key_url, iv, ts_url_list)
    
def judge_m3u8_encript_type(m3u8_content: str)->M3u8EncryptType:
    """判断m3u8 ts文件类型,比如是否使用明文,哪种加密方式等
    
    :param txt: m3u8文件内容
    :return: M3u8EncryptType枚举类型
    """

    # 如果是AES128加密,会有这行:#EXT-X-KEY:METHOD=AES-128,URI="https://aa.b.com/videos4/0f07ef21ec282c96ac32bb73d221f90c/crypt.key?auth_key=1742691847-72-0-d8787a27fe2eef60b071ac643f875c1b",IV=0xba25433fa8984b5abb5e0f5cc41f1a60
    index = m3u8_content.find("#EXT-X-KEY:METHOD=")
    if -1 == index:
        return M3u8EncryptType.PLAIN
    
    # 截取出加密方式
    start_index = index + len("#EXT-X-KEY:METHOD=")
    end_index = m3u8_content.find(",", start_index)
    enc_method = m3u8_content[start_index:end_index]
    enc_method = enc_method.strip()

    if enc_method == "AES-128":
        return M3u8EncryptType.AES128
    else :
        return M3u8EncryptType.UNKNOWN

def deal_ts_file_plain(ts_file_dir: str, ts_name_list: list[str], ts_merge_file_name: str)->bool:
    """处理未加密的ts文件,直接合并
    
    :param ts_file_dir: ts文件目录
    :param ts_name_list: ts文件名列表
    :param ts_merge_file_name: 合并后的ts文件名
    :return: 合并是否成功
    """
    try:
        with open(os.path.join(ts_file_dir, ts_merge_file_name), "wb") as merge_file:
            # 合并ts文件
            for ts_name in ts_name_list:
                with open(os.path.join(ts_file_dir, ts_name), "rb") as ts_file:
                    ts_file_data = ts_file.read()
                    merge_file.write(ts_file_data)
    except Exception as e:
        logging.error("合并ts文件失败:{}".format(e))
        return False
    
    return True

def request_encrypt_key(key_uri: str, referer_url: str)->bytes:
    """请求加密密钥

    :param key_url: 加密密钥获取url
    :param referer_url: 请求头部中的referer
    :return: 加密密钥
    """
    
    try:
        # 访问uri,获取加密密钥
        # 设置请求头
        headers = {
            'referer': referer_url,
            'User-Agent': common.USER_AGENT
        }
        response = requests.get(key_uri, headers=headers, timeout=10)
        if response.status_code != 200:
            logging.error("获取加密密钥失败, req_url:{}, status_code:{}".format(key_uri, response.status_code))
            return None
    except Exception as e:
        logging.error("访问加密密钥URI失败, req_url:{}, error:{}".format(key_uri, e))
        return None

    aes_key = response.content
    return aes_key

def deal_ts_file_aes128_new(aes_key: bytes, iv: str, ts_file_dir: str, ts_name_list: list[str], ts_merge_file_name: str)->bool:
    """处理AES128加密的ts文件,进行解密,并需要把所有解密后的文件数据,都追加到同一个文件中,ts的拼接顺序按m3u8文件中的顺序,后续使用ffmpeg等工具进行视频流的合并

    :param aes_key: 加密密钥
    :param iv: iv向量
    :param ts_file_dir: ts文件目录
    :param ts_name_list: ts文件名列表
    :param ts_merge_file_name: 合并后的ts文件名
    :return: 合并是否成功
    """

    # iv十六进制字符串转换成二进制byte
    iv_bytes = bytes.fromhex(iv)
    assert len(iv_bytes) == 16, "IV 必须是 16 字节"
    # ase_key是密钥,iv是向量(小写的16进制字符串)
    cipher = AES.new(aes_key, AES.MODE_CBC, iv_bytes)
    # 解密后就是m3u8文件,可以直接保存
    try:
        merge_file_path = os.path.abspath(os.path.join(ts_file_dir, ts_merge_file_name))
        with open(merge_file_path, "wb") as f:
            # 遍历ts文件列表,逐个解密并写入文件
            for ts_name in ts_name_list:
                ts_path = os.path.abspath(os.path.join(ts_file_dir, ts_name))
                with open(ts_path, "rb") as ts_file:
                    ts_file_data = ts_file.read()
                decrypted_data = cipher.decrypt(ts_file_data)
                plaintext = unpad(decrypted_data, AES.block_size)  # 移除PKCS7填充
                f.write(plaintext)
        logging.info(f"ts合并文件已保存到: {merge_file_path}")
    except Exception as e:
        logging.error("error:{}".format(e))
        return False
    
    return True

def title_to_filename(title: str)->str:
    """将标题转换为文件名,去除非法路径字符,空格替换成"-"
    
    :param title: 标题
    :return: 文件名
    """
    # 修复条件检查
    if not title:
        curr_ts = int(time.time())
        name = f"video_{curr_ts}"
        return name
    
    # 去除非法路径字符
    name = "".join([c for c in title if c.isalpha() or c.isdigit() or c in "._- "])
    # 空格替换成"-"
    name = name.replace(" ", "-")
    return name

第二个是main函数入口模块main.py

python 复制代码
#! /opt/homebrew/bin/python3
# 此处需要把路径修改为你自己本地的python可执行文件的路径,mac或者linux下使用which python3(或者which python)命令查看路径
import logging

from playwright.sync_api import sync_playwright
import os
import shutil
import sys
import common

# 导入parallel_download_m3u8模块
import parallel_download_m3u8 as pdm


# 定义main函数
def main(url:str):
    logging.info(f"开始下载视频:{url}")
    page_url = ""
    with sync_playwright() as p:
        # 启动浏览器,headless=True使用无头模式,如果要查看网页加载过程,可以设置为False,会打开一个浏览器窗口,能直观的看到网页加载过程
        browser = p.webkit.launch(headless=True)

        # 以下是模拟手机端的上下文
        # context = browser.new_context(
        #     user_agent='Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36',
        #     viewport={'width': 360, 'height': 640},
        #     device_scale_factor=2.625,
        #     has_touch=True,
        #     is_mobile=True,
        #     extra_http_headers={
        #         'Accept-Language': 'en-US,en;q=0.9'
        #     }
        # )

        # 以下是模拟PC端的上下文
        context = browser.new_context(
            user_agent=common.USER_AGENT,
            viewport={'width': 1920, 'height': 1080},
            extra_http_headers={
                'sec-ch-ua-platform':'"macOS"',
                'sec-ch-ua':'"Microsoft Edge";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
                'Accept-Language': 'en-US,en;q=0.9'
            }
        )

        page = context.new_page()

        # 监听响应
        page_has_been_closed = False
        dict_m3u8_content = {}
        title = ""
        def capture_response(response):
            nonlocal m3u8_content, title, page_has_been_closed, page_url
            #logging.info("url:{}".format(response.url))
            if "m3u8" in response.url and "#EXTM3U" in response.text():
                logging.info("捕获到m3u8文件URL:{}".format(response.url))
                logging.info("m3u8文件内容:{}".format(response.body()))
                dict_m3u8_content[response.url] = response.text()
                title = page.title()
                page_url = page.url
 
        page.on("response", capture_response)
        
        try:
            page.goto(url, timeout=60000, wait_until="load")
            # 滚动到底部
            page.evaluate("window.scrollTo(0, 3);")
            # 等待页面加载完成
            page.wait_for_timeout(6000)
        
        except Exception as e:
            if "been closed" in str(e) and not page_has_been_closed:
                logging.info("页面已关闭")
            else:
                logging.error("加载页面失败:{}".format(e))
        
        finally:
            # 关闭页面
            if not page_has_been_closed:
                logging.info("页面加载结束,关闭页面")
                page.close()


        logging.info(f"当前页面标题: {title}")
        context.close()
        browser.close()
    
    file_name = pdm.title_to_filename(title)
    logging.info(f"共捕获到{len(dict_m3u8_content)}个m3u8视频链接")

    # 获取网页浏览器中的url,比如https://a.com/movie/?viewKey=3b942de4147b413a 截取为https://a.com/
    index = page_url.rfind("://")
    if index == -1:
        logging.error(f"无法获取网址, url:{page_url}")
        return
    index = page_url.find("/", index+3)
    if index != -1:
        referer_url = page_url[:index+1]
    else:
        referer_url = page_url
    
    logging.info(f"referer_url:{referer_url}")
    
    vidio_count = 1
    for m3u8_req_url, m3u8_content in dict_m3u8_content.items():
        logging.info("m3u8 content:{}".format(m3u8_content))
        # 解析m3u8文件
        try:
            proc_result = pdm.parse_m3u8_file(m3u8_req_url, m3u8_content)
        except Exception as e:
            logging.error("解析m3u8文件失败:{}, url:{}".format(e, m3u8_req_url))
            continue
        
        aes_key = None
        enc_type = proc_result.enc_method
        logging.info("m3u8 encrypt type:{}".format(enc_type))
        if enc_type == pdm.M3u8EncryptType.UNKNOWN :
            logging.error("未知的加密类型, content:{}".format(m3u8_content))
            continue
        elif enc_type == pdm.M3u8EncryptType.AES128:

            # 先下载key文件
            aes_key = pdm.request_encrypt_key(proc_result.key_url, referer_url)
            if not aes_key:
                logging.error("下载key文件失败, m3u8 url:{}".format(m3u8_req_url))
                continue

        ts_url_list = proc_result.ts_url_list
        # 调用线程池下载ts文件
        ts_name_list = []
        download_result = False
        ts_file_dir = "./ts_files"
        try:
            download_result = pdm.download_m3u8_ts(ts_url_list, referer_url, ts_file_dir, ts_name_list)
        except Exception as e:
            logging.error("下载ts文件异常:{}, m3u8 url:{}".format(e, m3u8_req_url))
            continue

        if not download_result:
            logging.error("下载ts文件失败, m3u8 url:{}".format(m3u8_req_url))
            # 删除临时目录和所有ts文件
            if os.path.exists(ts_file_dir):
                shutil.rmtree(ts_file_dir)
            # 继续下一个视频
            continue
        ts_merge_file_name = "tsfile.tmp"
        # 根据加密类型做相应处理
        match enc_type:
            case pdm.M3u8EncryptType.PLAIN:
                deal_result = pdm.deal_ts_file_plain(ts_file_dir, ts_name_list, ts_merge_file_name)
                if not deal_result:
                    logging.error("处理未加密的ts文件失败")
                    continue

                # 无需解密,直接合并ts文件
            case pdm.M3u8EncryptType.AES128:
                deal_result = pdm.deal_ts_file_aes128_new(aes_key, proc_result.iv, ts_file_dir, ts_name_list, ts_merge_file_name)
                if not deal_result:
                    logging.error("处理加密的ts文件失败")
                    continue
            case _:
                logging.error("未知的加密类型")
                continue

        if len(dict_m3u8_content) == 1:
            mp4_file = f"{file_name}.mp4"
        else:
            mp4_file = f"{file_name}_{vidio_count}.mp4"

        # 用ffmpeg合并ts文件
        merge_file_path = os.path.abspath(os.path.join(ts_file_dir, ts_merge_file_name))
        mp4_path = os.path.abspath(mp4_file)
        cmd = f"ffmpeg -i \"{merge_file_path}\" -c copy \"{mp4_path}\""
        logging.info(f"执行ffmpeg命令: {cmd}")
        ret = os.system(cmd)
        # 判断命令是否执行成功
        if ret != 0:
            logging.error("合并ts文件失败,name: {}".format(mp4_file))
        else:
            logging.info("视频文件合并完成,name: {}".format(mp4_file))
        vidio_count += 1
        # 删除临时文件
        if os.path.exists(ts_merge_file_name):
            os.remove(ts_merge_file_name)

        if os.path.exists(ts_file_dir):
            shutil.rmtree(ts_file_dir)
                
def setup_logger():
    # 创建 Logger
    logger = logging.getLogger()
    logger.setLevel(logging.DEBUG)  # 设置全局日志级别

    # 定义日志格式
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d - %(funcName)s] - %(message)s'
    )

    # 1. 控制台 Handler(输出到屏幕)
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)  # 控制台日志级别
    console_handler.setFormatter(formatter)

    # 2. 文件 Handler(保存到文件)
    file_handler = logging.FileHandler('app.log', encoding='utf-8')
    file_handler.setLevel(logging.DEBUG)  # 文件日志级别
    file_handler.setFormatter(formatter)

    # 将 Handler 添加到 Logger
    logger.addHandler(console_handler)
    logger.addHandler(file_handler)


# 调用main函数
if __name__ == '__main__':
    if len(sys.argv) > 1:
        url = sys.argv[1]
    else:
        print("usage: python main.py <url>")
        exit(1)

    setup_logger()
    main(url)
相关推荐
万叶学编程3 分钟前
鸿蒙移动应用开发--ArkTS语法进阶实验
开发语言·javascript·ecmascript
chilling heart6 分钟前
JAVA---继承
java·开发语言·学习
黄雪超9 分钟前
JVM——JVM是怎么实现invokedynamic的?
java·开发语言·jvm
绿龙术士22 分钟前
C#与西门子PLC通信:S7NetPlus和HslCommunication使用指南
开发语言·c#
xiaolang_8616_wjl30 分钟前
c++_2011 NOIP 普及组 (1)
开发语言·数据结构·c++·算法·c++20
若水晴空初如梦43 分钟前
QT聊天项目DAY07
开发语言·qt
Luna_Lovegood_0011 小时前
Qt QGraphicsScene 的用法
开发语言·qt
月忆3641 小时前
Go语言接口实现面对对象的三大特征
开发语言·后端·golang
o0向阳而生0o1 小时前
35、C# 中的反射(Reflection)
开发语言·c#·.net
程序员曼布1 小时前
RabbitMQ 深度解析:从核心组件到复杂应用场景
java·开发语言·后端·rabbitmq