逆向工程Python爬虫——国税局发票查验平台

前言

这是一篇含金量很高的干货文章,笔者将手把手带领各位一步一步地实现爬取国家税务总局全国增值税发票查验平台(以下简称“查验平台”)。这个想法诞生在19年初,当时在做一款通过扫描二维码就可以查验发票的小程序。

当时由于笔者学艺尚浅,没办法模拟请求爬取查验平台,所以最终采用的技术方案是通过web自动化测试工具selenium控制浏览器去模拟查验步骤,即使这样,开发过程也是困难重重,不过最后笔者和伙伴们成功实现了整套流程,最后开发出的产品口袋发票夺得了包括2019微信小程序开发大赛赛区三等奖在内的多个奖项。
但是产品是无法真正上线的,因为通过selenium爬虫的方式实在是太消耗性能了,测试结果表明:百度云4核8G的服务器职能同时服务10人以内。
笔者一直不甘心,暗自下定决心:一定要实现模拟请求爬取。
那么闲话少说,我们开始吧。

查一张发票

第一步肯定是分析查验平台整体的逻辑,所以我们首先来真实地查验一张发票。
这里笔者使用的是Chrome 76.0.3809.132,是本文发布时的最新版本。
逆向工程Python爬虫——国税局发票查验平台_第1张图片

  • 这里我找到了一张发票,首先输入发票号码
    逆向工程Python爬虫——国税局发票查验平台_第2张图片
    我发现:当我的光标移动到发票号码输入框时发票代码右侧出现了一个对勾,这说明在发票代码失焦的时候会检测发票代码正确性,然后给用户一个反馈。
    这是我在京东买书的发票,是在北京开具的,这里可以了解一下发票号码的含义:百度百科
  • 接下来我们继续输入发票号码
    逆向工程Python爬虫——国税局发票查验平台_第3张图片
    有趣的事情出现了,发票号码失焦之后下面突然出现了验证码,这里我们得出结论:验证码请求的时机是在发票号码失焦之后
  • 继续输入开票日期和校验码后六位
    逆向工程Python爬虫——国税局发票查验平台_第4张图片
    此时查验按钮还不可点击
  • 输入验证码
    逆向工程Python爬虫——国税局发票查验平台_第5张图片
    此时我们了解到税务总局的验证码含有中文和英文,并且需要根据颜色指示输入,让我们多刷新几次验证码。
    逆向工程Python爬虫——国税局发票查验平台_第6张图片
    逆向工程Python爬虫——国税局发票查验平台_第7张图片
    逆向工程Python爬虫——国税局发票查验平台_第8张图片
    逆向工程Python爬虫——国税局发票查验平台_第9张图片
    我刷新了n次之后发现:验证码提示只有4种情况:输入全部、黄色、红色、蓝色
    查验按钮在所有信息填写完之后出现
  • 最后点击查验即可得到发票的真伪和详细信息

查看获取验证码的请求

  • 我将网页刷新然后F12打开Chrome的调试工具,点击Network然后将列表清空。
    逆向工程Python爬虫——国税局发票查验平台_第10张图片
  • 接下来我们重复刚才查验发票的操作,直到失焦发票号码输入框,然后观察验证码是怎么来的。
    逆向工程Python爬虫——国税局发票查验平台_第11张图片
    此时我们发现了验证码的请求,看一下详细信息。
    逆向工程Python爬虫——国税局发票查验平台_第12张图片
    这是一个jQuery发起的请求,笔者对jQuery了解的不深,不过我们继续看请求参数:
参数名 含义
callback 固定值
fpdm 发票代码
fphm 发票号码
r 看起来像是个用来签名的随机数
v 应该是版本号 是个固定值
nowtime 请求发起时的时间
area 简单猜测和地区有关系
publickey 签名(我们需要破解的东西)
_ 不知道这是什么

那么现在我们的目标很明确了,找到publickey的计算方法。
让我们来利用Chrome调试工具的动作追踪功能,首先定位发票号码的输入框,然后打开这个项目
逆向工程Python爬虫——国税局发票查验平台_第13张图片
下面的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,我们不清楚它们的作用,那么我们来利用调试工具的断点来执行语句。
逆向工程Python爬虫——国税局发票查验平台_第14张图片
加下来我们发现fpdm长度大于8,ahmch不执行,那我们就先不管。
afcdm函数是核心函数之一,它有打断篇幅在检测fpdm的合法性。

  • 第13行:
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
}

模拟验证码请求

getYzmXx

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

接下来我们需要实现那个查询信息的函数: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

通过调试工具找到源码,通过观察我们发现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))

逆向工程Python爬虫——国税局发票查验平台_第15张图片

大功告成!

你可能感兴趣的:(小聪明)