这是一篇含金量很高的干货文章,笔者将手把手带领各位一步一步地实现爬取国家税务总局全国增值税发票查验平台(以下简称“查验平台”)。这个想法诞生在19年初,当时在做一款通过扫描二维码就可以查验发票的小程序。
当时由于笔者学艺尚浅,没办法模拟请求爬取查验平台,所以最终采用的技术方案是通过web自动化测试工具selenium控制浏览器去模拟查验步骤,即使这样,开发过程也是困难重重,不过最后笔者和伙伴们成功实现了整套流程,最后开发出的产品口袋发票夺得了包括2019微信小程序开发大赛赛区三等奖在内的多个奖项。
但是产品是无法真正上线的,因为通过selenium爬虫的方式实在是太消耗性能了,测试结果表明:百度云4核8G的服务器职能同时服务10人以内。
笔者一直不甘心,暗自下定决心:一定要实现模拟请求爬取。
那么闲话少说,我们开始吧。
第一步肯定是分析查验平台整体的逻辑,所以我们首先来真实地查验一张发票。
这里笔者使用的是Chrome 76.0.3809.132,是本文发布时的最新版本。
参数名 | 含义 |
---|---|
callback | 固定值 |
fpdm | 发票代码 |
fphm | 发票号码 |
r | 看起来像是个用来签名的随机数 |
v | 应该是版本号 是个固定值 |
nowtime | 请求发起时的时间 |
area | 简单猜测和地区有关系 |
publickey | 签名(我们需要破解的东西) |
_ | 不知道这是什么 |
那么现在我们的目标很明确了,找到publickey的计算方法。
让我们来利用Chrome调试工具的动作追踪功能,首先定位发票号码的输入框,然后打开这个项目
下面的blur指的就是失焦操作,我们看到有面有一个js文件,打开它,并点击左下角格式化。
这里有必要说明一下,这个文件叫做“VM54403”并不是说真的有这么一个文件,而是说这个文件是由其他的js代码解码而来的虚拟文件。
$('#fphm').blur(function() {
var fphm = $("#fphm").val().trim();
if (fphm.length != 0 && fphm.length < 8) {
ahmch(fphm)
}
var fpdm = $("#fpdm").val().trim();
afcdm(fpdm);
acb(fplx)
});
这里有3个函数ahmch、afcdm、acb,我们不清楚它们的作用,那么我们来利用调试工具的断点来执行语句。
加下来我们发现fpdm长度大于8,ahmch不执行,那我们就先不管。
afcdm函数是核心函数之一,它有打断篇幅在检测fpdm的合法性。
var swjginfo = getSwjg(fpdm, 0);
这里出现了getSwjg函数,这是一个很重要的函数,我们来看看它是做什么的,定位断点到13行,然后F8执行到断点,我们得到了getSwjg函数的代码:
function getSwjg(fpdm, ckflag) {
var flag = "";
eval(function(p, a, c, k, e, d) {
e = function(c) {
return (c < a ? "" : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
}
;
if (!''.replace(/^/, String)) {
while (c--)
d[e(c)] = k[c] || e(c);
k = [function(e) {
return d[e]
}
];
e = function() {
return '\\w+'
}
;
c = 1;
}
;while (c--)
if (k[c])
p = p.replace(new RegExp('\\b' + e(c) + '\\b','g'), k[c]);
return p;
}('24 X=[{\'7\':\'12\',\'8\':\'13\',\'6\':\'0://3.G.4.2.1:5\',\'9\':\'0://3.G.4.2.1:5\'},{\'7\':\'14\',\'8\':\'Y\',\'6\':\'0://3.L.2.1:5\',\'9\':\'0://3.L.2.1:5\'},{\'7\':\'1j\',\'8\':\'1g\',\'6\':\'0://3.U.4.2.1\',\'9\':\'0://3.U.4.2.1\'},{\'7\':\'1k\',\'8\':\'1f\',\'6\':\'0://3.K.4.2.1:5\',\'9\':\'0://3.K.4.2.1:5\'},{\'7\':\'1a\',\'8\':\'18\',\'6\':\'0://3.R.4.2.1:5\',\'9\':\'0://3.R.4.2.1:5\'},{\'7\':\'1e\',\'8\':\'1h\',\'6\':\'0://3.m.4.2.1:5\',\'9\':\'0://3.m.4.2.1:5\'},{\'7\':\'1c\',\'8\':\'1b\',\'6\':\'0://3.q.2.1:5\',\'9\':\'0://3.q.2.1:5\'},{\'7\':\'1d\',\'8\':\'17\',\'6\':\'0://3.j.4.2.1:d\',\'9\':\'0://3.j.4.2.1:d\'},{\'7\':\'19\',\'8\':\'1l\',\'6\':\'0://3.f-n-
//省略部分乱码
)
var dqdm = null;
var swjginfo = new Array();
if (fpdm.length == 12) {
dqdm = fpdm.substring(1, 5)
} else {
dqdm = fpdm.substring(0, 4)
}
if (dqdm != "2102" && dqdm != "3302" && dqdm != "3502" && dqdm != "3702" && dqdm != "4403") {
dqdm = dqdm.substring(0, 2) + "00"
}
for (var i = 0; i < citys.length; i++) {
if (dqdm == citys[i].code) {
swjginfo[0] = citys[i].sfmc;
if (flag == 'debug') {} else {
swjginfo[1] = citys[i].Ip + "/WebQuery";
swjginfo[2] = dqdm
}
break
}
}
return swjginfo;
}
观察这个函数,我们发现它的作用是根据fpdm查询信息。其中有一段加密混淆的js代码,我们利用一个工具网站解密。
JavaScript Eval Encode/Decode
将eval函数和其中的代码拷贝进去,然后点击解密,我们得到一个js对象:
var citys = [{
'code': '1100',
'sfmc': '北京',
'Ip': 'https://fpcy.beijing.chinatax.gov.cn:443',
'address': 'https://fpcy.beijing.chinatax.gov.cn:443'
},
{
'code': '1200',
'sfmc': '天津',
'Ip': 'https://fpcy.tjsat.gov.cn:443',
'address': 'https://fpcy.tjsat.gov.cn:443'
},
{
'code': '1300',
'sfmc': '河北',
'Ip': 'https://fpcy.hebei.chinatax.gov.cn',
'address': 'https://fpcy.hebei.chinatax.gov.cn'
},
//省略后面的数据
这是我们需要的数据。
接下来我们回到afcdm函数,又发现了28行出现了关键的代码:
fplx = alxd(fpdm);
这里调用了一个alxd函数对发票类型进行处理。
function alxd(a) {
var b;
var c = "99";
if (a.length == 12) {
b = a.substring(7, 8);
for (var i = 0; i < code.length; i++) {
if (a == code[i]) {
c = "10";
break
}
}
if (c == "99") {
if (a.charAt(0) == '0' && a.substring(10, 12) == '11') {
c = "10"
}
if (a.charAt(0) == '0' && (a.substring(10, 12) == '04' || a.substring(10, 12) == '05')) {
c = "04"
}
if (a.charAt(0) == '0' && (a.substring(10, 12) == '06' || a.substring(10, 12) == '07')) {
c = "11"
}
if (a.charAt(0) == '0' && a.substring(10, 12) == '12') {
c = "14"
}
}
if (c == "99") {
if (a.substring(10, 12) == '17' && a.charAt(0) == '0') {
c = "15"
}
if (c == "99" && b == 2 && a.charAt(0) != '0') {
c = "03"
}
}
} else if (a.length == 10) {
b = a.substring(7, 8);
if (b == 1 || b == 5) {
c = "01"
} else if (b == 6 || b == 3) {
c = "04"
} else if (b == 7 || b == 2) {
c = "02"
}
}
return c
}
我们得到了根据fpdm计算fplx的函数。
继续观察afcdm函数,103行出现了请求验证码的函数:getYzmXx
这个文件都是控制验证码请求的,所以我们将它保存下来。
function getYzmXx() {
show_yzm = "1";
var fpdm = $("#fpdm").val().trim
var swjginfo = getSwjg(fpdm, 0);
var url = swjginfo[1] + "/yzmQuery";
var nowtime = showTime().toString();
var fpdmyzm = $("#fpdm").val().trim();
var fphmyzm = $("#fphm").val().trim();
var kjje = $("#kjje").val().trim();
var rad = Math.random();
var area = swjginfo[2];
var param = {
'fpdm': fpdmyzm,
'fphm': fphmyzm,
'r': rad,
'v': VVV,
'nowtime': nowtime,
'area': area,
'publickey': $.ckcode(fpdmyzm, nowtime)
};
$.ajaxSetup({
cache: false
});
yzmFlag = 1;
$.ajax({
type: "post",
url: url,
data: param,
dataType: "jsonp",
jsonp: "callback",
success: function(jsonData) {
//处理返回代码省略
},
timeout: 5000,
error: function(XMLHttpRequest, textStatus, errorThrown) {
if (retrycount == 9) {
jAlert("系统繁忙,请稍后重试!", "提示")
} else {
retrycount = retrycount + 1;
getYzmXx()
}
}
});
yzmWait = 2;
yzmTime($('#yzm_img'))
}
这部分代码很好懂,我们来删减一下:
function getYzmXx() {
var fpdm = $("#fpdm").val().trim();
var swjginfo = getSwjg(fpdm, 0);
var url = swjginfo[1] + "/yzmQuery";
var nowtime = showTime().toString();
var fpdmyzm = $("#fpdm").val().trim();
var fphmyzm = $("#fphm").val().trim();
var rad = Math.random();
var area = swjginfo[2];
var param = {
'fpdm': fpdmyzm,
'fphm': fphmyzm,
'r': rad,
'v': VVV,
'nowtime': nowtime,
'area': area,
'publickey': $.ckcode(fpdmyzm, nowtime)
};
$.ajax({
type: "post",
url: url,
data: param,
dataType: "jsonp",
jsonp: "callback",
success: function(jsonData) {
//处理成功返回省略
}
});
}
function showTime() {
var myDate = new Date();
var time = myDate.getTime();
return time
}
def getYzmXx(VVV, fpdm, fphmyzm):
'''
VVV:系统版本号
fpdm:发票代码
fphmyzm:发票号码
'''
swjginfo = getSwjg(fpdm, 0)
url = swjginfo[1] + "/yzmQuery"
nowtime = showTime()
rad = random.random()
area = swjginfo[2]
param = {
'fpdm': fpdm,
'fphm': fphmyzm,
'r': rad,
'v': VVV,
'nowtime': nowtime,
'area': area,
'publickey': ckcode(fpdm, nowtime)
}
s=requests.session()
s.headers['user-agent']="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36"
resp=s.post(url,data=param)
res=json.loads(resp.text)
return res,s
由于查验平台使用cookie,所以我们使用了requests库中的session来自动维持会话。
接下来我们需要实现那个查询信息的函数:getSwjg
def getSwjg(fpdm, ckflag):
citys = [
{
'code': '1100',
'sfmc': '北京',
'Ip': 'https://fpcy.beijing.chinatax.gov.cn:443',
'address': 'https://fpcy.beijing.chinatax.gov.cn:443'
},
{
'code': '1200',
'sfmc': '天津',
'Ip': 'https://fpcy.tjsat.gov.cn:443',
'address': 'https://fpcy.tjsat.gov.cn:443'
},
//省略部分数据
]
swjginfo = []
if len(fpdm) == 12:
dqdm = fpdm[1:5]
else:
dqdm = fpdm[0:4]
if dqdm != "2102" and dqdm != "3302" and dqdm != "3502" and dqdm != "3702" and dqdm != "4403":
dqdm = dqdm[0:2]+"00"
for city in citys:
if dqdm == city["code"]:
swjginfo.append(city["sfmc"])
swjginfo.append(city["Ip"] + "/WebQuery")
swjginfo.append(dqdm)
break
return swjginfo
这个很简单,照搬js就可以了。
这是第一堵高墙,我们模拟请求中的签名算法ckcode,继续使用调试工具找到ckcode的代码。
!function(n) {
var e, r = function(n, r) {
return e = "402880bd5c76166f015c903ee811504e",
n << r | n >>> 32 - r
}, c = function(n, r, c) {
return e = "402880bd5c76166",
n & c | r & ~c
};
n.extend({
ck: function(e, t, p, u, y, o) {
var d, i = c(t, e, p), f = n.encrypt(e), g = n.encrypt(u + y), a = r(e, t);
i = 2147483648 & e,
i += 2147483648 & t,
i += d,
i += d = 1073741824 & I,
a = i = n.encrypt(e) + n.bs.encode(n.encrypt(t)) + p;
var b = n.gen(i, a)
, v = n.encrypt(f) + g
, j = n.gen(b + n.gen(e, a) + v, g);
return n.prijm(e, t, p, u, y, o, j)
},
ckcode: function(e, r) {
var c = n.encrypt(e + r)
, t = n.encrypt(e) + n.bs.encode(n.encrypt(r))
, p = n.gen(t, c)
, u = n.encrypt(c)
, y = n.gen(p + n.gen(e, t) + u, t);
return n.pricd(e, r, y)
}
})
}(jQuery);
我们得到了这样一个文件,接下来我们要实现两个算法ck(后面一定用的到)和ckcode,但是我们遇到了阻碍,ck和ckcode包含了另外5个函数:encrypt、encode、gen、prijm、pricd
通过调试工具找到源码,通过观察我们发现encrypt函数加密过程中所有的字函数都在这份文件中,那么我们直接通过python执行js就可以了,这个函数很好解决。
首先确保安装了PyExecJS
pip3 install PyExecJS
编写脚本:
def encrypt(n):
js=r'''
var r = function (n, r) {
return n << r | n >>> 32 - r
},
t = function (n, r) {
var t, e, u, o, I;
return u = 2147483648 & n, o = 2147483648 & r, t = 1073741824 & n, e = 1073741824 & r, i = (1073741823 & n) + (1073741823 & r), t & e ? 2147483648 ^ i ^ u ^ o : t | e ? 1073741824 & i ? 3221225472 ^ i ^ u ^ o : 1073741824 ^ i ^ u ^ o : i ^ u ^ o
},
e = function (n, r, t) {
return n & r | ~n & t
},
u = function (n, r, t) {
return n & t | r & ~t
},
o = function (n, r, t) {
return n ^ r ^ t
},
//部分js代码省略
return (l(s) + l(d) + l(v) + l(S)).toLowerCase()
};
'''
ctx = execjs.compile(js)
return ctx.call("encrypt",n)
在Chorme的控制台调用一下原版的函数,核对一下算法有没有问题:
$.encrypt("qwer")
"962012d09b8170d912f0669f6d7d9d07"
然后执行python脚本:
MacBook-Pro-2:py bbfat$ python3 encrypt.Python
962012d09b8170d912f0669f6d7d9d07
OK!
本人博客
接下来我们写一个脚本,调用所有函数进行一次测试:
from yzm import *
from check import *
yzm_keys,s=getYzmXx('V1.0.07_001','011001900311','42558341')
print(yzm_keys["key1"]+'\n'+yzm_keys['key4'])
yzm=input("输入验证码:")
print(check(s,"011001900311","42558341","20190829","643785",yzm,yzm_keys))