链路拆解

好看视频这条链路比较适合写成逆向笔记,因为它没有一上来就进入复杂浏览器环境,而是可以拆成几步:

  1. 搜索接口:https://haokan.baidu.com/haokan/ui-search/pc/search/video
  2. 详情页:https://haokan.baidu.com/v?vid=<vid>
  3. 页面里提取 encrptedVideoMeta
  4. Base64 解码后用 guanghui456 做 XOR 解密
  5. JSON 里读取 clarityUrl,按 rank 选择最高画质

这里最关键的是第四步。它不是标准 AES 这类加密,而是简单的 Base64 + XOR。代码一旦拆开,其实很直观。

搜索接口签名

搜索接口的 sign 由页码、关键词、分页数量、毫秒时间戳和固定版本位拼出来,再做 MD5:

from hashlib import md5
import time
import urllib.parse

def build_search_params(keyword: str, page: int = 1, page_size: int = 20) -> dict:
    timestamp = int(time.time() * 1000)
    quoted = urllib.parse.quote(keyword)
    sign = md5(f"{page}_{quoted}_{page_size}_{timestamp}_1".encode()).hexdigest()
    return {
        "pn": page,
        "rn": str(page_size),
        "type": "video",
        "query": keyword,
        "sign": sign,
        "version": "1",
        "timestamp": str(timestamp),
    }

这类签名不是为了安全通信,更像是接口请求完整性校验。逆向时重点是找到拼接顺序、编码方式和时间戳粒度。

可运行解析代码

下面这份是整理后的可运行版本。它保留真实接口、真实字段和真实解密 key。个人 cookie 不写死,确实需要时通过环境变量 HAOKAN_COOKIE 注入。

import base64
import json
import os
import re
import urllib.parse
from dataclasses import dataclass
from hashlib import md5
import time

import requests

DOMAIN_URL = "https://haokan.baidu.com"
SEARCH_URL = "https://haokan.baidu.com/haokan/ui-search/pc/search/video"
DECRYPT_KEY = "guanghui456"

HEADERS = {
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
    "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
    "referer": "https://haokan.baidu.com/",
    "user-agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
    ),
}

if os.getenv("HAOKAN_COOKIE"):
    HEADERS["cookie"] = os.environ["HAOKAN_COOKIE"]


@dataclass
class HaokanVideo:
    vid: str
    title: str
    cover: str
    author: str
    duration: str
    video_url: str
    quality: str


def build_search_params(keyword: str, page: int = 1, page_size: int = 20) -> dict:
    timestamp = int(time.time() * 1000)
    sign = md5(f"{page}_{urllib.parse.quote(keyword)}_{page_size}_{timestamp}_1".encode()).hexdigest()
    return {
        "pn": page,
        "rn": str(page_size),
        "type": "video",
        "query": keyword,
        "sign": sign,
        "version": "1",
        "timestamp": str(timestamp),
    }


def search_haokan(keyword: str, page: int = 1, page_size: int = 20) -> list[dict]:
    response = requests.get(
        SEARCH_URL,
        params=build_search_params(keyword, page, page_size),
        headers=HEADERS,
        timeout=15,
    )
    response.raise_for_status()
    data = response.json()
    return data.get("data", {}).get("list", [])


def extract_haokan_vid(url_or_vid: str) -> str:
    value = (url_or_vid or "").strip()
    if re.fullmatch(r"\d{6,}", value):
        return value

    parsed = urllib.parse.urlparse(value)
    query = urllib.parse.parse_qs(parsed.query)
    for key in ("vid", "pd", "id"):
        item = query.get(key)
        if item and re.fullmatch(r"\d{6,}", item[0]):
            return item[0]

    match = re.search(r"(?:vid=|/v/|video/|haokan_)(\d{6,})", value)
    if match:
        return match.group(1)

    raise ValueError("未能从链接中提取好看视频 vid")


def decrypt_video_meta(encrypted_meta: str) -> dict:
    decoded = base64.b64decode(encrypted_meta).decode("utf-8")
    chars = []
    for index, char in enumerate(decoded):
        chars.append(chr(ord(char) ^ ord(DECRYPT_KEY[index % len(DECRYPT_KEY)])))
    return json.loads("".join(chars))


def fetch_detail(vid: str) -> dict:
    response = requests.get(
        f"{DOMAIN_URL}/v",
        params={"vid": vid},
        headers=HEADERS,
        timeout=20,
    )
    response.raise_for_status()
    response.encoding = "utf-8"

    found = re.findall(r'"encrptedVideoMeta":"(.*?)",', response.text)
    if not found:
        raise RuntimeError("详情页未找到 encrptedVideoMeta")
    return decrypt_video_meta(found[0])


def pick_best_video_url(detail: dict) -> dict:
    candidates = [
        item for item in detail.get("clarityUrl", [])
        if isinstance(item, dict) and item.get("url")
    ]
    if not candidates:
        raise RuntimeError("clarityUrl 里没有可用视频地址")
    return sorted(candidates, key=lambda item: item.get("rank", 0), reverse=True)[0]


def parse_haokan(url_or_vid: str) -> HaokanVideo:
    vid = extract_haokan_vid(url_or_vid)
    detail = fetch_detail(vid)
    best = pick_best_video_url(detail)
    author = detail.get("author") if isinstance(detail.get("author"), dict) else {}
    return HaokanVideo(
        vid=vid,
        title=detail.get("title", ""),
        cover=detail.get("cover", ""),
        author=author.get("name", "好看视频用户"),
        duration=str(detail.get("duration", "")),
        video_url=best["url"],
        quality=best.get("key") or best.get("name") or best.get("definition") or "最高画质",
    )


if __name__ == "__main__":
    # 搜索示例
    for item in search_haokan("麦小兜", page=1, page_size=5):
        print(item.get("title"), item.get("vid"))

    # 详情解析示例:传 vid 或包含 vid 的 URL 都可以
    result = parse_haokan("123456789")
    print(result)

复盘

好看视频这条链路的亮点不在请求头伪装,而在页面内加密字段的处理。encrptedVideoMeta 解开之后,数据结构很完整,包含标题、封面、作者、时长和多清晰度地址。

我会把这类解析器做成两层:搜索只返回列表和 vid,详情解析再按需触发。这样界面第一次加载更快,也能避免每一页搜索都去解析所有视频直链。