JavaScript进阶-拦截和修改fetch response内容-以B站为例

一. 前言

img

B站页面普遍采用的模式, 主html带有部分初始化页面的数据, 之后的更多数据采用异步加载的方式实现, 这是一种经典的即保证页面渲染呈现, 同时不影响页面的加载速度的页面模式.

首页

顶部一小部分的视频

img

检索结果页面

第一个搜索页面

img

当搜索的的url不是首页, 理论上应该会出现以下错误:

https://search.bilibili.com/all?vt=50926548&keyword=%E6%9F%AF%E5%8D%97&from_source=webtop_search&spm_id_from=333.880&search_source=5&page=2&o=30

img

播放页面

侧边栏的相关视频, 当加载下一个视频, 相关的信息载入就是通过api加载.

img

二. 分析过程

打开控制板 => 网络 => xhr.

img

找到发起请求的脚本来源: https://s1.hdslb.com/bfs/static/laputa-search/client/assets/index.d50424d6.js.

代码很容易阅读, 就是对fetch的封装(对于大型站点而言, B站不管是页面还是代码(没有过多的历史兼容包袱, 没有刻意的代码混淆...), api都绝对是良心的体现, 干净, 简单, 实用, 希望B站的基业长青吧).

// 代码太长, 删掉小部分

Fa = class {
    constructor(e={}) {
        re(this, "defaults");
        re(this, "_req");
        re(this, "_techPbCodeRep");
        this.asyncDataKey = is,
        this.serverConfigKey = Yi,
        this.defaults = I(I({}, sr), e),
        this._techPbCodeRep = !1,
        typeof fetch == "function" ? this._req = fetch.bind(window) : this._req = ()=>{
            throw new Error("no request function")
        }
    }
    create(e={}) {
        return new Fa(e)
    }
    $get(e, t={}, s) {
        let n;
        return s ? n = we(I({}, s), {
            params: t
        }) : t.params ? n = t : n = {
            params: t
        },
        n.method = "GET",
        n.params && (e = fn(e, n.params)),
        this._send(e, n)
    }
    $post(e, t={}, s) {
        let n;
        return s ? n = we(I({}, s), {
            body: t
        }) : t.body ? n = t : n = {
            body: t
        },
        n.method = "POST",
        n.withCSRF && (n.body = n.body || {},
        n.body.csrf = zi("bili_jct")),
        Wi(n),
        this._send(e, n)
    }
    get() {
        var e = arguments;
        return new Promise((t,s)=>G(this, null, function*() {
            const [n,a] = yield this.$get(...e);
            if (n) {
                s(n);
                return
            }
            t(a)
        }))
    }
    post() {
        var e = arguments;
        return new Promise((t,s)=>G(this, null, function*() {
            const [n,a] = yield this.$post(...e);
            if (n) {
                s(n);
                return
            }
            t(a)
        }))
    }
    _send(e, t) {
        const s = I({}, t)
          , n = ji(this.defaults.baseURL, e);
        return new Promise(a=>{
            var o;
            try {
                const i = ((o = s == null ? void 0 : s.ctx) == null ? void 0 : o.headers) || {};
                delete i.host,
                delete i["accept-encoding"],
                s.headers = I(I({
                    Accept: "application/json, text/plain, */*"
                }, i), s.headers || {}),
                s.credentials = s.credentials || this.defaults.credentials;
                const r = er("_sendUrl", n, s)
                  , u = Qi(n, s);
                if (u) {
                    r.finish(),
                    a([null, u]);
                    return
                }
                let h = !1;
                const g = Ji(n, s)
                  , f = s.timeout || this.defaults.timeout
                  , _ = s.onRequest || this.defaults.onRequest
                  , w = s.onResponse || this.defaults.onResponse
                  , p = E=>{
                    let C;
                    const x = Uo
                      , L = new x
                      , y = setTimeout(()=>{
                        if (L.abort(),
                        h = !0,
                        E) {
                            r.setTag("error", !0),
                            a([`request ${n} TIMEOUT`, null]);
                            return
                        }
                        r.setTag("retry_url", n),
                        p(!0)
                    }
                    , f);
                    if (g)
                        if (Ui(g))
                            C = g;
                        else {
                            clearTimeout(y),
                            r.finish(),
                            a([null, g]);
                            return
                        }
                    if (!C)
                        if (_) {
                            const b = _({
                                url: n,
                                config: s
                            });
                            C = this._req(b.url, we(I({}, b.config), {
                                signal: L.signal
                            }))
                        } else
                            C = this._req(n, we(I({}, s), {
                                signal: L.signal
                            }));
                    const m = (b,k)=>{
                        var D, P, V, N, O, se, ne, X, ce, j, Q, ee, F, q, J, ie;
                        if (!He && this._techPbCodeRep && (window == null ? void 0 : window.__biliMirror__)) {
                            const $e = `${(V = (P = (D = window.__biliMirror__) == null ? void 0 : D.options) == null ? void 0 : P.origin) != null ? V : 0}.${(se = (O = (N = window.__biliMirror__) == null ? void 0 : N.options) == null ? void 0 : O.module) != null ? se : 0}`;
                            (ie = No) == null || ie({
                                type: (k == null ? void 0 : k.ok) === !1 ? "error" : "custom",
                                eventId: $e + ((k == null ? void 0 : k.ok) === !1 ? ".ERROR.catchError" : ".DATA.requestReport"),
                                msg: {
                                    code: (X = (ne = k == null ? void 0 : k.code) != null ? ne : k == null ? void 0 : k.status) != null ? X : "emptyCode",
                                    msg: (j = (ce = k == null ? void 0 : k.message) != null ? ce : k == null ? void 0 : k.statusText) != null ? j : "",
                                    url: (Q = b == null ? void 0 : b.url) != null ? Q : "",
                                    params: (F = (ee = b == null ? void 0 : b.config) == null ? void 0 : ee.params) != null ? F : {},
                                    method: (J = (q = b == null ? void 0 : b.config) == null ? void 0 : q.method) != null ? J : ""
                                }
                            })
                        }
                    }
                    ;
                    C.then(b=>{
                        clearTimeout(y),
                        !(!E && h) && (w(a, {
                            url: n,
                            config: s
                        }, b, m),
                        r.finish())
                    }
                    ).catch(b=>{
                        var k;
                        m({
                            url: n,
                            config: s
                        }, {
                            ok: !1,
                            statusText: (k = b == null ? void 0 : b.message) != null ? k : b
                        }),
                        clearTimeout(y),
                        !(!E && h) && (r.setTag("error", !0),
                        a([b, null]))
                    }
                    )
                }
                ;
                p(!1)
            } catch (i) {
                a([i, null])
            }
        }
        )
    }
}

img

设置断点调试

// 将封装的请求对象 this._req  = fetch.bind(window);
typeof fetch == "function" ? this._req = fetch.bind(window) : this._req = ()=> {
            throw new Error("no request function")
}

// 发起请求
C = this._req(b.url, we(I({}, b.config), {
                                signal: L.signal
                            }))

找到代码中的关键部分.

三. 代码实现

{
    function test(from) {
        console.log(from + ': test');
    }
    const f = test.bind(window);

    test = new Proxy(test, {
        apply(...args) {
            args[2][0] = 'proxy +' + args[2][0];
            return Reflect.apply(...args);
        }
    });
    test('me');

    f('bind');
}

proxy +me: test
bind: test

函数进行了bind绑定之后, 对于原函数的Proxy无法拦截

const fetch = window.fetch;
    window.fetch = (...args) =>
    (async (args) => {
        const url = args[0];
        const ad_list = ['trace', 'beacon'];
        if (ad_list.some((e) => url.includes(e))) throw new new UserException('fuck tencent');
        else return await fetch(...args);
    })(args);

上述模式的代码无法拦截上述的fetch.bind(window)的数据请求操作.

// 上述的代码是无法捕捉到这个fetch
this._req = fetch.bind(window)

需要获得这个bind之后的"新"函数.

// 对拦截到的函数进行二度的封装
const trap = (fn) => {
    fn = async (...args) => {
        let [url, config] = args;
        let response = await fetch(url, config);
        if (url.startsWith('https://api.bilibili.com/x/web-interface/wbi/search/type?__refresh')){
            const json = () =>
            // response只能读取一次, 需要.clone()做一个备份, 然后再进行操作
            response
                .clone()
                .json()
                .then((data) => {
                    // 对检索title, 作者全部进行修改
                    data.data.result.forEach(e=> {
                            e.author = 'HLA';
                            e.title = "nothing to show";
                    });
                    return data
                });
            response.json = json;
        }
        return response;
    };
    return fn
}

// 对所有函数的bind操作进行拦截
Function.prototype.bind = new Proxy(Function.prototype.bind, {
    apply(...args) {
        if (args.length > 1) {
            const a = args[1];
            const m = a.name;
            if (m && m.includes('fetch')) args[1] = trap(a);
        }
        return Reflect.apply(...args);
    }
});

B站检索api的数据结构, 结构非常清晰易读:

img

3.1 结果

可以看到, 修改后的response可以返回被B站自身脚本正常处理.

img

这就意味着, 可以实现对B站的内容实现过滤, 甚至高度自定义修改, 例如载入其他api接口的信息返回等.

由于是异步载入的数据, 这就意味着修改html页面元素过程的某些操作也是可以拦截, 进行修改的.

{
    HTMLAnchorElement.prototype.setAttribute = new Proxy(HTMLAnchorElement.prototype.setAttribute, {
        apply(...args) {
            console.log(args);
            Reflect.apply(...args);
        }
    });
}

例如, 干预标签 a的属性操作.

四. 小结

Proxy, Reflect对于干预原脚本的执行提供了绝对强大的武器, 相比于重写相关函数的实现, 代理模式让干预变得非常简单, 不需要复杂的重构, 只是对其中的某些参数进行干预就能够实现很好的干预效果, 同时不影响原脚本的执行.

以上内容仅作为交流学习, 请勿用于非法用途.