难度: ★☆☆☆☆ 1星

一、缘起

目标站点: https://www.tvmao.com/program/CCTV-CCTV1-w3.html

这个网站第一次接触是在17年刚毕业的时候在一家公司接手维护公司大佬写的项目,那个时候没做过爬虫,这是接触的第一个有JS反爬的网站,还是有些纪念意义的,一转眼几年过去了,网站的反爬策略貌似还是跟印象中差不多,而我似乎也没什么长进,我与君共蹉跎。

二、分析

打开一个节目单列表,比如这个页面:

https://www.tvmao.com/program/CCTV-CCTV1-w3.html

这个页面展示了CCTV1频道一天的节目单,上午的节目单它是随着页面doc返回的,这个没什么好搞的,而下午和晚上的节目单则是ajax懒加载,而这个ajax请求有一个加密参数p,本次就是要搞定这个参数加密。

首先打开上面那个节目单的地址,然后打开开发者工具,切换到Network,把无关请求清除掉,然后单击页面上的“查看更多”加载更多节目单:

爬虫笔记之电视猫节目单爬取_爬虫

捕捉到了懒加载的ajax请求:

爬虫笔记之电视猫节目单爬取_爬虫_02

这个ajax请求的地址为:

https://www.tvmao.com/api/pg?p=xxx

切换到Sources,然后给这个url打一个xhr断点:

爬虫笔记之电视猫节目单爬取_爬虫_03

然后刷新页面,重新点“加载更多”,让它进入xhr断点,然后格式化代码向前追溯调用栈,在一个匿名函数的栈帧里找找到了传参数发请求的地方:

爬虫笔记之电视猫节目单爬取_爬虫_04

将鼠标悬停到86行的A.d上,然后单击弹出框里的地址跟进入:

爬虫笔记之电视猫节目单爬取_爬虫_05

注意到这个代码的标题框是VMxxx,这段代码可能是用了eval加密之类的,但我们已经拿到代码了,所以就不去管那些了。

然后把这段代码拷贝出来,做个静态分析即可:

var A = {
    _keyStr: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
    _keyStr2: "KQMFS=DVGO",

    /**
     * 这个函数其实并没有看,扫了一眼看着像是base64,然后就在console上调用它加密一个字符串:
     * A.J("CC11001100")
     * 得到"Q0MxMTAwMTEwMA==",然后base64对它解码之后得到原字符串,证明这是一个标准的base64加密
     * 所以,折叠不看了...
     *
     * @param a
     * @returns {string|string}
     * @constructor
     */
    J: function (a) {
        var b = "";
        var c, chr2, chr3, enc1, enc2, enc3, enc4;
        var i = 0;
        a = A._C(a);
        while (i < a.length) {
            c = a.charCodeAt(i++);
            chr2 = a.charCodeAt(i++);
            chr3 = a.charCodeAt(i++);
            enc1 = c >> 2;
            enc2 = ((c & 3) << 4) | (chr2 >> 4);
            enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
            enc4 = chr3 & 63;
            if (isNaN(chr2)) {
                enc3 = enc4 = 64
            } else if (isNaN(chr3)) {
                enc4 = 64
            }
            b = b + this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) + this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4)
        }
        return b
    },

    H: function (a) {
        a = a.toString();
        var b = '';
        for (var i = 0; i < a.length; i++) {
            b += this._keyStr2[a.charAt(i)]
        }
        for (var i = 0; i < a.length; i++) {
            b += this._keyStr[a.charAt(i)]
        }
        return b
    },

    _C: function (a) {
        a = a.replace(/\r\n/g, "\n");
        var b = "";
        for (var n = 0; n < a.length; n++) {
            var c = a.charCodeAt(n);
            if (c < 128) {
                b += String.fromCharCode(c)
            } else if ((c > 127) && (c < 2048)) {
                b += String.fromCharCode((c >> 6) | 192);
                b += String.fromCharCode((c & 63) | 128)
            } else {
                b += String.fromCharCode((c >> 12) | 224);
                b += String.fromCharCode(((c >> 6) & 63) | 128);
                b += String.fromCharCode((c & 63) | 128)
            }
        }
        return b
    },
    E: function (a) {
        $(':input[name="ed"]', a).val(A.J('l' + $(".ed", a).val() + 'o'))
    },
    B: function (a) {
        var b = (new Date()).getTime();
        if (a != undefined)
            return A.J(a + '|' + b);
        else
            return A.J('' + b)
    },

    /**
     *
     * step 6:
     *
     * 返回页面上第一个form的a属性
     *
     * @param u
     * @returns {*}
     */
    e: function (u) {
        // u --> "a"
        // // document.querySelector("form").querySelector("input[class='baidu']")
        // 并没有选到东西...
        var x = 1;
        var f = $('form').first();
        var a = f.find("input[class='baidu']");
        if (a != undefined) {
            x = 2
        } else if (u != undefined) {
            x = u
        }
        if (f == undefined)
            return x;
        // 所以兜了半天最后返回的还是form的a属性
        // document.querySelector("form")
        // 30B972D97E1572D06EAA84CDA91A136DB0
        return f.attr('a')
    },

    /**
     *
     * step 5:
     * 这一步就是获取页面上第一个form的submit按钮的id属性
     *
     * @param e
     * @returns {*}
     */
    c: function (e) {
        var v;
        var f = $('form').first();
        if (f == undefined)
            return "";
        var s = f.find("*[type='submit']");
        if (s == undefined) {
            v = f.find("input[class='qq']");
            if (v == undefined)
                return "";
            v = e
        }
        // 在console上模拟这个过程,选取这个元素:
        // document.getElementsByTagName("form")[0].querySelector("*[type='submit']");
        // 拿到其id属性为: A50CB26A1B14FFF05ECA58F9128FE059406FED4EFD
        v = s.attr('id');
        return v
    },

    /**
     *
     * step 2: 跟进来的是这个方法,但是实际上这里并不先被执行,先执行最下面的立即执行方法,然后执行这里
     *
     * @param p 本次调用是 "a"
     * @param h 本次调用是 "src"
     * @returns {string}
     */
    d: function (p, h) {

        // h --> "src"
        var v = A.w(h);

        // 混淆视听的,x在这两个地方的赋值根本没被用到
        var a = $("div.fix");
        var x = a || p;
        if (a != undefined) {
            x = h || $("s.fix1")
        }
        // 真正有用的赋值是这里
        // 获取到页面上第一个表单的submit按钮的id属性
        x = A.c();

        var b = new Date();
        var c = b.getUTCDate();
        var d = b.getDay();
        var i = d == 0 ? 7 : d;
        i = i * i;
        var F = this._keyStr.charAt(i);

        return F + A.J(x + "|" + A.e(p)) + v
    },

    /**
     * step 3:
     *
     * @param v
     */
    w: function (v) { // v --> "src"
        var t = $("head");
        var a = "|";
        if (t == undefined) {
            tl = "/"
        } else {
            tl = v
        }

        // tl --> "src"
        // A.J("|07BBCD432D5102A1B885F27E8988AAB4AC8BF81B26C74F565501E65C69")
        var r = A.J(a + k(tl));
        // r --> "fDA3QkJDRDQzMkQ1MTAyQTFCODg1RjI3RTg5ODhBQUI0QUM4QkY4MUIyNkM3NEY1NjU1MDFFNjVDNjk="
        return r
    },

    s: function (a, b) {
        var c = this._keyStr.charAt(37);
        return A.J(c + a)
    }
};

// step 1: 下面的这一段在js加载的时候就先执行


// 只是定义了个k函数,在A.w里面调用了一下这个
var k = function (a) {
    // step 4:
    // 就是获取页面上第一个form的q属性
    // 在console上执行 document.getElementsByTagName("form")[0];
    // 它的q属性是类似于这样的: 07BBCD432D5102A1B885F27E8988AAB4AC8BF81B26C74F565501E65C69

    var f = $('form').first();
    if (f == undefined)
        return "";
    var b = f.attr('id');
    if (b == undefined)
        f.attr('id', a);
    return f.attr('q')
};

// 然后是一个立即执行的函数,这个函数给一个表单及一些链接添加了ek参数,但是似乎也并没用到,先不管
$(function () {
    //
    var b = $('<input type="hidden" name="ek"/>');
    b.val(A.B());
    $('form[name="frmlogin"]').append(b);
    $('a[class^="by"]').each(function () {
        var a = $(this).attr("href") + "&ek=" + encodeURIComponent(A.B());
        $(this).attr("href", a)
    })
});

逻辑很清晰了,就不需要扣代码,根据这些逻辑用python实现即可。

三、编码实现
#!/usr/bin/env python3
# encoding: utf-8
"""
@author: CC11001100
"""
import base64
import datetime
from urllib.parse import quote

import requests
from bs4 import BeautifulSoup

session = requests.session()


def crawl(url):
    headers = {
        "Accept": "application/json, text/javascript, */*; q=0.01",
        "Accept-Encoding": "gzip, deflate, br",
        "Accept-Language": "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7",
        "Host": "www.tvmao.com",
        "Pragma": "no-cache",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36",
    }
    # 节目单的上半部分没有加密,这里不再解析
    html = session.get(url, headers=headers).text

    # for debug
    with open("./response-01.html", "w", encoding="UTF-8") as f:
        f.write(html)

    p = get_param_p(html)
    print(f"计算出 p = {p}")

    headers["Referer"] = url
    headers["X-Requested-With"] = "XMLHttpRequest"
    url = "https://www.tvmao.com/api/pg?p=" + quote(p)
    response = session.get(url, headers=headers).json()

    # for debug
    with open("./response-02.html", "w", encoding="UTF-8") as f:
        f.write(response[1])

    print(response)


def get_param_p(html):
    doc = BeautifulSoup(html, features="html.parser")
    form = doc.select_one("form")

    d = datetime.datetime.now()
    week = d.weekday() - 1
    if week == 0:
        week = 7
    week = week * week
    f = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="[week]

    x = form.select_one("button[type=submit]")["id"]
    t1 = b64_s(x + "|" + form["a"])

    v = b64_s("|" + form["q"])

    return f + t1 + v


def b64_s(s):
    """
    各种算算看的晕晕,为了避免混淆视听,将不重要内容尽量缩短
    :param s:
    :return:
    """
    return base64.b64encode(s.encode("UTF-8")).decode("UTF-8")


if __name__ == "__main__":
    crawl("https://www.tvmao.com/program/CCTV-CCTV1-w3.html")

 

仓库:

https://github.com/CC11001100/misc-crawler-public/tree/master/001-anti-crawler-js-re/01-004-www.tvmao.com

 

请注意爬虫文章具有时效性,本文写于2020-11-25日。