一.写在前面
https://acgn.ttson.cn/。这是一个和原神有关的文本转语音的网站,效果还是很不错的,我忽然突发奇想,我能不能利用爬虫获取该网站的接口来实现自主的调用呢?想法很好,说干就干,只是没有想到这一下就搞了两个下午加一个上午才搞定,并且在这其中,还发现了一种不错的调用JS代码的方法,于是想着写下来记录。
二.准备工作,分析网站
还是老样子打开我们的浏览器开发工具,分析在生成语音的过程中的网络请求,如下图,每一次只有三个网络请求:
这里面比较重要的就是第二个请求,是返回音频地址的请求,我们把它打开看看,有没有什么加密参数:
好像并没有什么加密的参数,于是我就以为这个网站完全没有任何反爬的措施,就普通的写了一个requests请求,也确实获取到了信息,但是当我拿这个返回的音频地址去请求音频的时候,有情况发生了,得到的音频是null,没有任何数据。这让我感到很意外,于是我又试着生成几个音频去获取网络请求,终于发现了一些端倪,
这可能就是一个加密参数,没有在负载参数里面,而是在请求头里面。而且我发现这个参数似乎是不变的,于是我又完善了请求头的信息,试着去爬取,结果最后获取到的音频还是空,这到底是怎么回事?经过一段时间的琢磨,我终于理清了是怎么回事。
1.这个加密参数是和时间有关的加密参数,有效时间是10分钟。
2.第一个网络请求是options请求,里面有几个重要的信息:content-type,x-checkout-header,x-client-header,也就是说我们的请求头里面必须要带这三个参数。除了我们这个加密参数,这个checkout参数也是很扯,他会变,而且变法很奇怪,有时候它的值是_checkout,有时候又变成了_checkout_,没错,就是一个下划线的区别就会导致拿不到信息,就算你的加密参数对了也没用。
三.加密逻辑分析
既然我们知道了加密参数是哪个,我们就开始寻找加密逻辑吧,首先尝试全局搜索,不过没用,搜索结果是没有,不过事后才知道,如果搜索的改变一下首字母的大小写,是可以搜到的,不过当时并没有想到这个,所以我就用了另外一个方法,hook,也就是钩子技术,因为这个是请求头里面的加密参数,利用hook能很容易找到它被设置的地方,怎么使用hook可以去看我之前的文章,这里就不多说了,hook代码如下:
// ==UserScript==
// @name hookX-Client-header
// @namespace http://tampermonkey.net/
// @version 2024-03-07
// @description try to take over the world!
// @author You
// @match https://acgn.ttson.cn/
// @icon https://www.google.com/s2/favicons?sz=64&domain=ttson.cn
// @grant none
// ==/UserScript==
(function () {
var org = window.XMLHttpRequest.prototype.setRequestHeader;
window.XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
if (key == 'X-Client-header') {
debugger;
}
return org.apply(this, arguments);
};
})();
利用油猴插件注入这段hook代码,我们很容易就找到了加密的地方
代码里面的t值就是我们的加密参数,它是调用一个s函数和一个toString函数生成的。到这里,我没有多想,就将这一个JS文件复制到了vscode里面,再把这个函数绑定到全局变量上面,然后开始了漫长的补环境,但是补了很久很久,发现根本补不完,我这才意识到,这条路可能是行不通的。于是我想到了第二种办法,直接读懂它的加密逻辑,然后复现出来。其实事后我都不知道当时自己哪来的勇气去尝试搞懂里面的逻辑,虽然这些JS代码并没有混淆,但是想要把其中的加密逻辑分离出来真的还是要花费很多功夫。
上面就是我打的一些断点,通过单步运行一步一步搞懂它的加密过程,最后写成一段JS代码如下:
//这个函数用来实时生成和时间相关的字符串
function genStr()
{
let e = (new Date).toISOString().slice(0, 13);
let mystr="alex"+e;
return mystr;
}
let myhash=0
//这个函数是将上面的字符串转换为字节序列
function changeTimeString(e) {
for (var t = e.length, n = [], r = 0; r < t; r++)
n[r >>> 2] |= (255 & e.charCodeAt(r)) << 24 - r % 4 * 8;
return n
}
//主加密函数,得到最终加密后生成的序列值
function _doFinalize () {
var n = changeTimeString(genStr());
var r = 8 * 17;
var a = 8 * 17;
n[a >>> 5] |= 128 << 24 - a % 32;
var o = 0
, l = r;
n[15 + (a + 64 >>> 9 << 4)] = 16711935 & (o << 8 | o >>> 24) | 4278255360 & (o << 24 | o >>> 8),
n[14 + (a + 64 >>> 9 << 4)] = 16711935 & (l << 8 | l >>> 24) | 4278255360 & (l << 24 | l >>> 8),
//n被转换成了16个元素的字节序列,再通过_process进一步处理
_process(n);
//下面这行循环非常重要,会得到最终加密后的值
let i=[];
for (var u = myhash, s = 0; s < 4; s++) {
var c = u[s];
u[s] = 16711935 & (c << 8 | c >>> 24) | 4278255360 & (c << 24 | c >>> 8);
i.push(u[s]);
}
//这个i就是最终加密后的值
return i
}
//将16个元素的字节序列进行进一步处理的函数
function _process (t) {
//var n, r = this._data, a = r.words, o = r.sigBytes, l = 16, i = o / (4 * l), s = (i = t ? e.ceil(i) : e.max((0 | i) - this._minBufferSize, 0)) * l, c = e.min(4 * s, o);
var s=16;
var a=t;
if (s) {
for (var d = 0; d < s; d += 16)
_doProcessBlock(a, d);
//16元素的字节序列被转换成了新的序列
n = a.splice(0, s);
}
}
function _doProcessBlock (e, t) {
var i=[-680876936, -389564586, 606105819, -1044525330, -176418897, 1200080426, -1473231341, -45705983, 1770035416, -1958414417, -42063, -1990404162, 1804603682, -40341101, -1502002290, 1236535329, -165796510, -1069501632, 643717713, -373897302, -701558691, 38016083, -660478335, -405537848, 568446438, -1019803690, -187363961, 1163531501, -1444681467, -51403784, 1735328473, -1926607734, -378558, -2022574463, 1839030562, -35309556, -1530992060, 1272893353, -155497632, -1094730640, 681279174, -358537222, -722521979, 76029189, -640364487, -421815835, 530742520, -995338651, -198630844, 1126891415, -1416354905, -57434055, 1700485571, -1894986606, -1051523, -2054922799, 1873313359, -30611744, -1560198380, 1309151649, -145523070, -1120210379, 718787259, -343485551];
for (var n = 0; n < 16; n++) {
var r = t + n
, a = e[r];
e[r] = 16711935 & (a << 8 | a >>> 24) | 4278255360 & (a << 24 | a >>> 8)
}
//o应该是个定值,这个o好像很重要
var o = [1732584193, 4023233417, 2562383102, 271733878]
, l = e[t + 0]
, u = e[t + 1]
, v = e[t + 2]
, f = e[t + 3]
, h = e[t + 4]
, m = e[t + 5]
, g = e[t + 6]
, y = e[t + 7]
, b = e[t + 8]
, w = e[t + 9]
, x = e[t + 10]
, O = e[t + 11]
, C = e[t + 12]
, k = e[t + 13]
, S = e[t + 14]
, _ = e[t + 15]
, P = o[0]
, j = o[1]
, E = o[2]
, M = o[3];
P = s(P, j, E, M, l, 7, i[0]),
M = s(M, P, j, E, u, 12, i[1]),
E = s(E, M, P, j, v, 17, i[2]),
j = s(j, E, M, P, f, 22, i[3]),
P = s(P, j, E, M, h, 7, i[4]),
M = s(M, P, j, E, m, 12, i[5]),
E = s(E, M, P, j, g, 17, i[6]),
j = s(j, E, M, P, y, 22, i[7]),
P = s(P, j, E, M, b, 7, i[8]),
M = s(M, P, j, E, w, 12, i[9]),
E = s(E, M, P, j, x, 17, i[10]),
j = s(j, E, M, P, O, 22, i[11]),
P = s(P, j, E, M, C, 7, i[12]),
M = s(M, P, j, E, k, 12, i[13]),
E = s(E, M, P, j, S, 17, i[14]),
P = c(P, j = s(j, E, M, P, _, 22, i[15]), E, M, u, 5, i[16]),
M = c(M, P, j, E, g, 9, i[17]),
E = c(E, M, P, j, O, 14, i[18]),
j = c(j, E, M, P, l, 20, i[19]),
P = c(P, j, E, M, m, 5, i[20]),
M = c(M, P, j, E, x, 9, i[21]),
E = c(E, M, P, j, _, 14, i[22]),
j = c(j, E, M, P, h, 20, i[23]),
P = c(P, j, E, M, w, 5, i[24]),
M = c(M, P, j, E, S, 9, i[25]),
E = c(E, M, P, j, f, 14, i[26]),
j = c(j, E, M, P, b, 20, i[27]),
P = c(P, j, E, M, k, 5, i[28]),
M = c(M, P, j, E, v, 9, i[29]),
E = c(E, M, P, j, y, 14, i[30]),
P = d(P, j = c(j, E, M, P, C, 20, i[31]), E, M, m, 4, i[32]),
M = d(M, P, j, E, b, 11, i[33]),
E = d(E, M, P, j, O, 16, i[34]),
j = d(j, E, M, P, S, 23, i[35]),
P = d(P, j, E, M, u, 4, i[36]),
M = d(M, P, j, E, h, 11, i[37]),
E = d(E, M, P, j, y, 16, i[38]),
j = d(j, E, M, P, x, 23, i[39]),
P = d(P, j, E, M, k, 4, i[40]),
M = d(M, P, j, E, l, 11, i[41]),
E = d(E, M, P, j, f, 16, i[42]),
j = d(j, E, M, P, g, 23, i[43]),
P = d(P, j, E, M, w, 4, i[44]),
M = d(M, P, j, E, C, 11, i[45]),
E = d(E, M, P, j, _, 16, i[46]),
P = p(P, j = d(j, E, M, P, v, 23, i[47]), E, M, l, 6, i[48]),
M = p(M, P, j, E, y, 10, i[49]),
E = p(E, M, P, j, S, 15, i[50]),
j = p(j, E, M, P, m, 21, i[51]),
P = p(P, j, E, M, C, 6, i[52]),
M = p(M, P, j, E, f, 10, i[53]),
E = p(E, M, P, j, x, 15, i[54]),
j = p(j, E, M, P, u, 21, i[55]),
P = p(P, j, E, M, b, 6, i[56]),
M = p(M, P, j, E, _, 10, i[57]),
E = p(E, M, P, j, g, 15, i[58]),
j = p(j, E, M, P, k, 21, i[59]),
P = p(P, j, E, M, h, 6, i[60]),
M = p(M, P, j, E, O, 10, i[61]),
E = p(E, M, P, j, v, 15, i[62]),
j = p(j, E, M, P, w, 21, i[63]),
//最终的加密后的序列值
o[0] = o[0] + P | 0,
o[1] = o[1] + j | 0,
o[2] = o[2] + E | 0,
o[3] = o[3] + M | 0,
myhash=o
//console.log("this._hash.words:",o)
}
function s(e, t, n, r, a, o, l) {
var i = e + (t & n | ~t & r) + a + l;
return (i << o | i >>> 32 - o) + t
}
function c(e, t, n, r, a, o, l) {
var i = e + (t & r | n & ~r) + a + l;
return (i << o | i >>> 32 - o) + t
}
function d(e, t, n, r, a, o, l) {
var i = e + (t ^ n ^ r) + a + l;
return (i << o | i >>> 32 - o) + t
}
function p(e, t, n, r, a, o, l) {
var i = e + (n ^ (t | ~r)) + a + l;
return (i << o | i >>> 32 - o) + t
}
function stringify (e) {
for (var t = e, n = 16, r = [], a = 0; a < n; a++) {
var o = t[a >>> 2] >>> 24 - a % 4 * 8 & 255;
r.push((o >>> 4).toString(16)),
r.push((15 & o).toString(16))
}
return r.join("")
}
//console.log(stringify(_doFinalize()))
const express = require('express');
const app = express();
const port = 3000; // 选择一个你喜欢的端口
// 使用body-parser中间件来解析POST请求的请求体
app.use(express.json());
// 处理POST请求
app.get('/', (req, res) => {
res.send(stringify(_doFinalize()));
});
// 启动服务器
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
整个加密过程中涉及到的函数就是上面这些,他们之间环环相扣缺一不可,剥离出这些加密的过程就像是拔出一颗大树的过程,因为很多变量和函数都会牵扯到别的变量和函数。我大致解释一下加密的过程,首先是生成一个和时间有关的原始字符串,然后把这段字符串转换成最初的字节序列,再将这段字节序列处理成更复杂的字节序列,然后利用这个字节序列去处理一个固定的含四个元素的序列,将处理得到的值再转换成字符串,这就是整个加密过程。最后写了一个简单的服务器是为了python调用而准备的。
如此我们便能成功爬取到音频数据了。
四.playwright与网络劫持
在完成爬取之后,我开始思考一个问题,既然在node.js环境中模拟调用网页的JS代码有时候会很困难,那我们有没有办法直接在浏览器环境中调用呢?这样我们就可以不用补环境了,也不用去分析那屎一样的加密逻辑了。
有这样的办法么?有!我参考了崔进才的python网络爬虫实战了解到了这个厉害的playwright,它是微软开发的一个自动化测试的一个库,我们可以利用它来替换掉网页中的JS文件为我们自己的本地JS文件,这样我们就可以在JS代码里面注入我们自己的代码,比如将某个关键的方法绑定到window对象上,然后再用playwright提供的方法去执行,这样原来的网页就在毫无察觉的情况下运行了我们的JS代码。不过遗憾的是崔进才在书中给出的那个例子过于简单且不够普遍而且还有一定的错误,所以我查询了一些资料,总结了更一般的用法。我们首先来了解几个关键的方法。
# 拦截特定请求
page.route("**/api/data", handler=intercept_route)
route函数需要传入两个参数,第一个是route_pattern
(字符串):用于匹配请求 URL 的模式。支持通配符 *
和 **
。如果要匹配所有的JS文件路径的话,可以这样写:"**/*.js",通配符 *
表示匹配任意字符序列(除了 /
),而 **
表示匹配任意字符序列,包括 /
。第二个参数是handler
(函数):拦截器函数,接收两个参数:route
和 request
。route
对象用于控制请求的响应,request
对象表示当前请求的信息。我们伪造返回的响应的逻辑就在这个里面。
这个拦截器函数很好写。
def intercept_route(route, request):
if "index.495ee96c.js" in request.url:
route.fulfill(
status=200,
headers={"Content-Type": "application/javascript"},
body=js_content,
)
else:
route.continue_()
这个函数表达的意思就是,检查该JS路径是否包含在url中,如果是的话,就伪造一个响应,利用的是fulfill函数,
在 Playwright 中,route.fulfill()
方法用于提供自定义的响应,可以用于替代原始的网络请求响应。该方法需要传入以下参数:
status
(int): 响应的 HTTP 状态码。headers
(dict): 响应的 HTTP 头部。body
(str|bytes): 响应的主体内容。可以是字符串或字节流。如果是字符串,将会自动编码为 UTF-8 字节。
因此,使用 route.fulfill()
方法时,需要提供这三个参数来构造一个完整的 HTTP 响应,以替代原始的响应内容。
continue_函数的意思是如果没有就原来该怎么样就怎么样。
evaluate()
是一个用于在浏览器页面中执行 JavaScript 代码的方法。它允许你在当前页面的上下文中执行自定义的 JavaScript 代码,并且可以返回执行结果。
就像这样:
result = page.evaluate(expression)
了解完上面的一些主要函数之后,我们尝试来替换原网页的JS文件,并在这个JS文件里面添加我们自己的代码。我们要替代的JS文件是这个
我们把原本的JS代码保存到本地,并添加上一些自己的代码:
解释一下,我们在原本JS文件里面改了两个地方,一个是添加了一行注释,用来表示这是被替代掉的JS文件,然后是第二个地方添加了一个自己的函数,这样我们就可以在全局调用这个加密函数得到返回值,而不用去了解它的加密过程。
python代码如下,
from playwright.sync_api import sync_playwright
import time
from datetime import datetime
BASE_URL = 'https://acgn.ttson.cn/'
with open('test.js','r') as f:
js_content=f.read()
def intercept_route(route, request):
if "index.495ee96c.js" in request.url:
route.fulfill(
status=200,
headers={"Content-Type": "application/javascript"},
body=js_content,
)
else:
route.continue_()
with sync_playwright() as p:
browser = p.chromium.launch(channel='msedge',headless=False)
page = browser.new_page()
page.route("**/*.js", handler=intercept_route)
page.goto(BASE_URL)
time.sleep(20)
# result=page.evaluate("window.encode")
# print(result)
for i in range(10):
result = page.evaluate('''() => {
return window.encode("%s")
}''' % (datetime.now().isoformat()[:13]))
print(result)
browser.close()
我们运行一下:
可以看到原本的JS文件已经被我们替代掉了,但是,你会发现,输出的结果是一堆报错:
这是为什么?经过我的一番摸索,大概知道了:这个网站只有你输入文本并点击生成才会触发加密算法,这样我们的自定义的函数才能绑定到window上,才能被我们调用。不然的话,很多函数和变量都没有被定义,自然不可能运行成功,这也是我们之前直接把代码复制到node.js环境中尝试补环境而很难成功的主要原因。
所以我预留了20秒的操作时间进行一次语音生成。让我们再来一次,先手动生成一个语音,再看看结果:
这样函数成功绑定到了全局变量上面,我们自然也就调用成功了,如此这般我们就可以传入自己的参数随时进行调用了,利用浏览器本身的环境,我们就可以省去补环境这一操作带来的麻烦。如果有的朋友前面没认真看,说为什么这里加密结果都是一样的,这是因为我前面说过了,这个加密参数是和时间有关的,十分钟内产生的加密参数都是一样的。这个方法唯一的缺陷就是打开浏览器需要一点时间,但是也就几秒钟,只要打开之后,再次调用我们的自定义函数,其速度就和你在本地运行一样快了。我认为这是和利用其他自动化工具进行所见即所爬的策略是有本质区别的,利用这种方法,我们可以想象,假如我们需要爬取1000个视频,而每个视频都有一个加密参数,那么利用这种方法我们可以迅速生成这1000个加密参数,进而进行快速爬取,而不用手动或自动的滑动设备了。
同理,之前的抖音加密参数XB我们也可以利用这种方法,而且更加简单,因为我们去往某个用户页面之后,它的加密函数和变量已经被定义和调用过了,所以我们根本不需要任何操作就能得到它的加密结果,也不需要登录和去填验证码之类的。