进入目标站点,搜索对应的关键词。

页面将跳转至企业信息页面,用户可以在页面底部进行分页操作,查看更多信息。

企查查采用 AJAX 请求 动态加载数据。每次翻页时,前端通过发送 AJAX 请求获取新的数据,并更新页面显示。

步骤一:识别加密

找接口

点击 searchMulti请求,展开 Result 字段,查看返回的公司信息,这些就是我们需要的爬取数据。

找参数

对比请求差异

可以发现请求头中有一组动态变化的键值对。请求头中,键名如 887ef0912c等,键值如 d08856655b等,每次请求都会变化。

分析负载参数

请求的负载中包含分页参数,如 pageIndexpageSizesearchKey

结论:

需要破解的是动态生成的请求头,尤其是包含奇怪键名和键值的部分。

这些键名和值无法通过全局搜索直接找到,需要进一步通过 XHR 断点和栈追踪分析。

步骤二:参数定位

找位置

利用XHR 断点分析,追溯动态请求头的生成(具体过程略)

在代码执行到 o.default(e) 后,发现 e.headers 中出现了加密后的请求头。

步骤三:参数分析

找逻辑键值对i:u的生成

( _____ , ______)为逗号运算符:它的作用是从左到右执行表达式。

(0, o.default)(e) 

会首先执行 0,然后执行 o.default 。由于 0 并不影响执行结果,这种写法实际上等价于 o.default(e)

为什么不直接写 o.default(e)?

原因:

  • 涉及到 JavaScript 中 this 的指向问题。

  • 如果写成 o.default(e),那么函数中的 this 会绑定到 o 对象。

  • 使用 (0, o.default)(e),通过逗号运算符将 thiso 解绑,确保函数的 this 指向 全局对象,避免绑定错误。

接下来进入o.default(e) 函数里具体分析加密逻辑的核心部分

提取路径:

 var t = e.url.replace(e.baseURL, "");

功能:目的是将完整的请求路径中的基础域名部分去除,保留接口路径部分。

示例:

e.url = '/api/search/searchMulti'

e.baseURL = 'https://www.qcc.com'

由于 e.url 并不包含域名,替换操作对 t 的值没有影响。

参数序列化:

n = a.default.stringify(e.params || {});

功能:将 e.params 转换为 URL 参数字符串。如果有额外的参数如分页、查询关键词等,它们将被添加到路径后。

 结果:e.paramsundefined,则 n 为空字符串,拼接操作将跳过。

路径转小写:

t = t.toLowerCase();

功能:将路径中的大写字母转换为小写。

结果:例如,将 /api/search/searchMulti 转为 /api/search/searchmulti

生成和写入加密键值对:

var i = (0, o.default)(t, e.data)
    , u = (0, r.default)(t, e.data, (0, s.default)());

e.headers[i] = u;

功能:将生成的加密键值对写入到请求头中。

结果:请求头中的键值对现在包含了由 o.defaultr.default 生成的加密参数。

参数名 i 的生成逻辑

i = (0, o.default)(t, e.data)

参数解析:

  • t 是接口路径,固定值:'/api/search/searchmulti'

  • e.data 是请求负载参数,即我们在发起请求时发送的数据。

跳转到 o.default 方法的内部实现,来看它是如何处理传入的参数的

var e = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {} ;
var t = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "/").toLowerCase()

它的作用是做一个参数合法性判断

  • e: 如果传入了第二个参数,它会被赋值给 e;否则,e 默认为空对象 {}。

e = e.data={"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}

  • t: 第一个参数 t 是接口路径。如果没有传入,默认为 '/',并转换成小写。

t = '/api/search/searchmulti';

n = JSON.stringify(e).toLowerCase();

n: 将 e(即请求负载)转换成小写的 JSON 字符串:

n='{"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}'

return (0,
    a.default)(t + n, (0,
    o.default)(t)).toLowerCase().substr(8, 20)

代码可简化为    a.default(t + n, o.default(t)).toLowerCase().substr(8, 20)

解析a.default:

  • 第一个参数t+n:t + n = '/api/search/searchmulti{"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}'

  • 第二个参数o.default(t):

(0,o.default):i.default = function() {
                for (var e = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "/").toLowerCase(), t = e + e, n = "", i = 0; i < t.length; ++i) {
                    var o = t[i].charCodeAt() % a.default.n;
                    n += a.default.codes[o]
                }
                return n
            }

该函数先获取传入的首个参数(无则默认 "/")并转为小写,将其拼接成原字符串 2 倍长度的新字符串;随后遍历该新字符串的每个字符,取字符 ASCII 码对预设数字a.default.n取模,用得到的索引从预设字符数组a.default.codes中取值,拼接所有取值结果后返回。

  • 输入固定(比如接口路径是'/api/search/searchmulti' )→ 转小写后仍固定 → 拼接成 t+t 也固定;

  • 遍历每个字符时,charCodeAt() 是固定值(每个字符的 ASCII 码唯一);

  • a.default.na.default.codes 是预设的固定值(只要这两个值不变);

  • 取模和数组取值的结果也完全固定 → 最终拼接的字符串必然固定。

因此,o.default(t)是一个固定字符串'iLAgiklLN8QiklLN8QrLi4giLAgiklLN8QiklLN8QrLi4g'

最终拼接与加密:

将拼接后的字符串 t + n 和固定字符串 (0, o.default)(t) 传入 a.default 方法进行处理。

进入a.default 方法

打上断点让网页执行到这一步,再次点击进去a.default 方法内部

从对象结构能看出的细节

  • sigBytes: 64:说明使用的是 SHA-512 哈希算法(SHA-512 输出 64 字节,即 512 位)。

  • words: Array(16):WordArray 内部用 32 位整数数组存储数据,64 字节 = 16 × 32 位,符合 SHA-512 的输出长度。

  • $superinit 等方法:这是典型的 CryptoJS 类库结构(CryptoJS 是前端常用的加密库,HMAC-SHA 系列是其核心功能)。

结论

  1. 这是 HMAC-SHA512 签名:从 sigBytes: 64 可确定哈希算法是 SHA-512,整体是 HMAC-SHA512 签名流程。

  2. 输出是固定长度的十六进制签名字符串:最终 .toString() 得到的是 128 个十六进制字符(64 字节),只要输入的 t(密钥)、n(算法)、e(数据)固定,签名结果就完全固定

  3. 库来源大概率是 CryptoJS:对象结构(wordssigBytes、原型方法)和 API 风格(init/finalize/toString)都和 CryptoJS 高度一致。

密钥n:'iLAgiLklNklLN8QiBl4kgi4lNigk4B4lAllviLAgiLklNklLN8QiBl4kgi4lNigk4B4lAllv'

明文e:'/api/search/searchmulti{"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}'

加密参数名 i 就是 HMAC-SHA512 结果的处理结果, 加密结果转换为小写。截取加密结果的第 9 个字符开始的 20 个字符,得到最终的请求参数名 i。

参数名 u的生成逻辑

u = (0, r.default)(t, e.data, (0, s.default)())

代码可简化为u =r.default(t, e.data, s.default())

参数解析:

  • t 是接口路径,固定值:'/api/search/searchmulti'

  • e.data是请求负载参数,即我们在发起请求时发送的数据。即{"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}

  • s.default:接下来跳转到的内部实现

这段代码是典型的字符串混淆写法:

把完整代码拆成单个字符存入数组,避免直接暴露敏感代码(比如这里的 window.tid)。运行时通过 join() 还原字符串,再用 eval() 执行,本质就是返回 window.tid

因此,(0,s.default)() 的值为window.id,即固定的字符串'5b57835becb264c135d5245777469061'

三个参数准备完毕,最后回到r.default 方法的实现中

变量赋值:

var e = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {},
    t = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : "",
    n = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "/").toLowerCase(),
    i = JSON.stringify(e).toLowerCase();
  • e:如果存在第二个参数,则使用该参数,否则默认为一个空对象 {}。

e = e.data={"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}

  • t:如果存在第三个参数,则使用该参数,否则默认为空字符串 ""

t = (0,s.default)() = eval(window.tid) = '5b57835becb264c135d5245777469061'

  • n:如果传入了第一个参数,则使用该参数,并将其字符串全部转换为小写,否则默认为 '/'。

n = t = '/api/search/searchmulti'

  • i:这是请求负载参数 e 转换为 JSON 字符串后,再将所有字符转为小写格式。

i = e = '{"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}'

拼接与加密

return (0,a.default)(n + "pathString" + i + t, (0,o.default)(n))

代码可简化为 return a.default(n + "pathString" + i + t, o.default(n))

解析a.default:

第一个参数n + "pathString" + i + t

'/api/search/searchmultipathString{"searchkey":"腾讯","pageindex":2,"pagesize":20}5b57835becb264c135d5245777469061'

第二个参数(0,o.default)(n)

进入o.default() 里分析

i.default = function() {
                for (var e = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "/").toLowerCase(), t = e + e, n = "", i = 0; i < t.length; ++i) {
                    var o = t[i].charCodeAt() % a.default.n;
                    n += a.default.codes[o]
                }
                return n
            }

此前已经分析过o.default(t)是一个固定字符串'iLAgiLklNklLN8QiBl4kgi4lNigk4B4lAllviLAgiLklNklLN8QiBl4kgi4lNigk4B4lAllv'

将两个生成的参数传入 a.default 方法进行加密:

密钥:'/api/search/searchmultipathString{"searchkey":"腾讯","pageindex":2,"pagesize":20}5b57835becb264c135d5245777469061'

明文:'/api/search/searchmulti{"searchKey": "腾讯","pageIndex": 2,"pageSize": 20}'

加密参数名 u 就是 HMAC-SHA512 结果的处理结果

步骤四:参数调用

把加密函数及其调用逻辑封装成一个可调用函数(JS 文件内)

let CryptoJS = require("crypto-js");

function get_headers(searchKey,pageIndex){
    const e = JSON.stringify({"searchKey":searchKey ,"pageIndex": pageIndex,"pageSize": 20}).toLowerCase(),
    key = 'iLAgiLklNklLN8QiBl4kgi4lNigk4B4lAllviLAgiLklNklLN8QiBl4kgi4lNigk4B4lAllv',
    url = '/api/search/searchmulti',
    tid = '5b57835becb264c135d5245777469061',

    data1 = url + e,
    data2 = url + "pathString" + e + tid,
    i = CryptoJS.HmacSHA512(data1, key).toString().substr(8,20);
    u = CryptoJS.HmacSHA512(data2, key).toString();

    return [i,u];
}

//console.log(get_headers('腾讯',2));

在 Python 中用 execjs 调用该函数得到加密参数的值

# 导入第三方库:requests用于发送HTTP请求,execjs用于执行JavaScript代码
import requests
import execjs

def get_data(searchKey, pageIndex):
    """
    调用企查查搜索接口,获取企业搜索结果数据
    :param searchKey: 要搜索的企业关键词(如"腾讯")
    :param pageIndex: 搜索结果的页码(从1开始)
    :return: 接口返回的JSON数据,请求失败则返回None
    """
    # TODO: 由于Cookies具有时效性,需定期将Cookie替换为浏览器中最新的有效Cookie
    cookies = {
        'QCCSESSID': '12d1636cb38608b04b62fd7354',
        'qcc_did': 'e18248b9-0e00-426f-9258-73b3409580e2',
        'UM_distinctid': '19cf0d4beed1466-0b500430236984-4c657b58-144000-19cf0d4beee1c30',
        '_c_WBKFRo': 'VHqGqfFvIcsyaGr41ZX31dnXMJfXwZ4S4dyLj07A',
        'acw_tc': '781bad3917738244885214300ef2a4f84df04d3fa2a2666f73f63d4815b197',
        'CNZZDATA1254842228': '879773729-1773567000-%7C1773824496',
    }

    # HTTP请求头,模拟浏览器行为,包含反爬校验字段
    headers = {
        '887ef1a7310e4c57204a': 'd0885f245dbab3ea37a0dca80029effacb87845b5a248a320da0ccbb552222f2799d7e197ecf96d846c488519e88b4effc19a070e0f7485b7cdd8771ca5d3468',
        'accept': 'application/json, text/plain, */*',
        'accept-language': 'zh-CN,zh;q=0.9',
        'content-type': 'application/json',
        'origin': 'https://www.qcc.com',
        'priority': 'u=1, i',
        'referer': 'https://www.qcc.com/web/search?key=%E8%85%BE%E8%AE%AF',
        'sec-ch-ua': '"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"',
        'sec-ch-ua-mobile': '?0',
        'sec-ch-ua-platform': '"Windows"',
        'sec-fetch-dest': 'empty',
        'sec-fetch-mode': 'cors',
        'sec-fetch-site': 'same-origin',
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0',
        'x-pid': '3bd88ed4e4d72bc10dbe5cde7dda722d',
        'x-requested-with': 'XMLHttpRequest',
        # 'cookie': '...' 已通过cookies参数传递,此处注释避免重复
    }

    # POST请求的JSON数据体,包含搜索关键词和分页参数
    json_data = {
        'searchKey': searchKey,
        'pageIndex': pageIndex,
        'pageSize': 20,  # 每页返回20条结果
    }

    # 读取本地JS加密脚本文件
    with open('qichacha.js', 'r', encoding='utf-8') as f:
        js_code = f.read()

    # 编译JS代码为可执行环境
    js = execjs.compile(js_code)
    # 调用JS函数get_headers,生成动态校验参数i和u
    q = js.call('get_headers', searchKey, pageIndex)
    i = q[0]  # 动态参数名(如加密后的header键)
    u = q[1]  # 动态参数值
    # 将动态校验参数添加到请求头中,完成反爬校验
    headers[i] = u

    try:
        # 发送POST请求到企查查搜索接口
        response = requests.post(
            'https://www.qcc.com/api/search/searchMulti',
            cookies=cookies,
            headers=headers,
            json=json_data
        )
        # 主动抛出HTTP状态码异常(如404、500等)
        response.raise_for_status()
        # 解析并返回接口返回的JSON数据
        return response.json()
    except requests.exceptions.RequestException as e:
        # 捕获所有网络请求相关异常(连接失败、超时、HTTP错误等)
        print(f"请求失败: {e}")
        return None
    except Exception as e:
        # 捕获其他未知异常(如JS执行错误、JSON解析错误等)
        print(f"其他错误: {e}")
        return None


if __name__ == '__main__':
    # 主程序入口:测试调用get_data函数
    searchKey = '腾讯'  # 搜索关键词
    pageIndex = 2       # 要查询的页码
    result = get_data(searchKey, pageIndex)
    # 打印接口返回结果
    print(result)