说明:本文聚焦响应真伪识别、状态链还原和工程落地边界,不讨论验证码绕过。

一、问题从哪来

这次研究不是从“我要逆向一个站”开始的,而是从一个很具体的业务故障开始的。

在做企业信息自动化时,搜索链路经常长这样:

  1. 输入企业名
  2. 请求企业搜索页
  3. 从搜索结果里抽公司名、公司 ID、详情页地址
  4. 再去拉企业详情、分支机构、关联公司

这条链一旦第一步拿错,后面整个资产归属就会偏掉。更麻烦的是,它不一定直接报错。我们这次碰到的症状是:

  • 请求返回 200
  • 页面里有 window.pageData
  • 有时候甚至还有 resultList
  • 但提取出来的不是目标公司,或者干脆是伪造结果

这已经不是“请求能不能通”的问题,而是数据完整性问题。程序把假页当真页,后续公司确认、资产归属、影子资产识别都会被带偏。

所以这次研究的目标很明确:

  1. 先确认真实搜索链到底是什么
  2. 搞清楚哪些字段决定“这页是真是假”
  3. 还原 ab... 这类动态状态的生成方式
  4. 给后续代码落地一套最简判断方案

二、先把对象讲清楚:我们到底在看什么

文章里会反复出现 4 个对象,如果不先说明,后面读起来会很突兀。

1. 搜索入口 /s?q=...&t=0

这是企业搜索页的入口。输入一个公司名,浏览器最终会落到类似下面的地址:

GET https://aiqicha.baidu.com/s?q=%E5%8C%97%E4%BA%AC%E6%90%9C%E7%8B%97%E4%BF%A1%E6%81%AF%E6%9C%8D%E5%8A%A1%E6%9C%89%E9%99%90%E5%85%AC%E5%8F%B8&t=0

如果页面正常,它会在 HTML 里内联一段:

window.pageData = {...}; window.isSpider = ...

后续企业名、结果列表、推荐项,基本都从这里提取。

2. UA

这里不是泛指浏览器概念,而是 HTTP 请求头里的 User-Agent。这次分析里确认,爱企查搜索链会直接把 UA 作为风控输入之一。

样本里选用的 UA 是:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36

3. ab...

这是一个按小时变化的 cookie 子项,形态像:

ab177566040=b59ae6443698d3647ba14903fa4753031775661328310

它不是固定 key,也不是固定 value。后面会讲它的生成逻辑。

4. xaf.jsabcliteMIAO_ET

这些不是一开始就知道的,而是顺着浏览器请求链和前端脚本一点点挖出来的。

  • xaf.js:负责生成/更新 before 等前置状态
  • abclite:负责 miao.baidu.com/abdr 这一支上报和 cookie 刷新
  • MIAO_ET:浏览器本地存储里的一个状态值,后来在解密后的明文字段里能对上

也就是说,它们不是平行概念,而是同一条状态机上的不同节点。

三、第一轮排查:为什么 200 了还是错

1. 先看“看起来正常”的返回

拿企业名 北京搜狗信息服务有限公司 做基线,真实结果页大致长这样:

HTTP 200
final_url = /s?q=北京搜狗信息服务有限公司&t=0
html_length ≈ 282947
title = 北京搜狗信息服务有限公司 - 爱企查
queryWord = 北京搜狗信息服务有限公司
result.queryStr = 北京搜狗信息服务有限公司
result.resultList.length = 10

这类页面后续提取公司名、公司 ID 都没有问题。

2. 再看“也返回 200”的异常页

最开始误导我们的,不是验证码,而是假页。典型样本:

HTTP 200
final_url = /s?q=北京搜狗信息服务有限公司&t=0
html_length ≈ 18558
title = ""
queryWord = 北京搜狗信息服务有限公司
result.queryStr = 城厢
result.resultList.length = 2

注意几点:

  • HTTP 200
  • window.pageData 存在
  • queryWord 还是输入值
  • 甚至还有 resultList

queryStr 已经被偷偷改成了别的词。这种页如果直接喂给解析逻辑,程序会以为“我拿到了真实搜索结果”,其实已经被投喂了假数据。

3. 还有一类“弱结果页”

这类页也很像正常响应,但不能拿来当正式搜索结果:

HTTP 200
final_url = /s?q=北京搜狗信息服务有限公司&t=0
html_length ≈ 13742
title = ""
queryWord = 北京搜狗信息服务有限公司
result.queryStr = null
result.resultList.length = 0
result.absorbed.length = 5

这里的 absorbed 更像推荐项,不是正式搜索结果。如果把它们直接并进候选列表,根公司确认链会被污染。

4. 直接失败页长什么样

验证码页:

final_url = https://wappass.baidu.com/static/captcha/tuxing_v2.html?...
html_length ≈ 1488
title = 百度安全验证

限制页:

final_url = https://aiqicha.baidu.com/acount/accessrestriction

这两类最容易识别,反而不是分析重点。

四、不要一上来猜 cookie,先把真实请求链画出来

一开始最容易犯的错误,是把问题理解成:

Cookie + GET /s?q=...

实际抓浏览器真实会话后,请求链是这样的:

  1. POST https://sofire.baidu.com/data/ua/ab.json
  2. POST https://miao.baidu.com/abdr
  3. POST https://miao.baidu.com/abdr?_o=https://aiqicha.baidu.com
  4. GET https://miao.baidu.com/abdr?data=...
  5. GET https://aiqicha.baidu.com/s?q=...&t=0

这件事非常关键。它说明:

  • 搜索 GET 只是最后一步
  • 真页返回之前,浏览器已经跑了一整套状态刷新

也正是因为这个顺序,我们后面才会去反查:

  • 哪个脚本在写 ab...
  • before 是怎么来的
  • miao/abdr 到底返回了什么

五、这些变量和脚本是怎么找到的

这一段专门回答“为什么文章里会突然出现 ab...xaf.jsMIAO_ET”。

1. 从真假响应对比开始

第一步不是看 JS,而是先对比 3 类页面:

  • 真页
  • 弱结果页
  • 假页

先确认:

  • 假页里也有 pageData
  • 弱结果页里也有 pageData
  • 所以单看 200pageData 一定不够

这个阶段拿到的是症状,还不是根因。

2. 再看请求链的 Initiator

在浏览器 DevTools 里,顺着 /s?q=... 往前找,会看到前置的:

  • sofire.baidu.com/data/ua/ab.json
  • miao.baidu.com/abdr

再看这些请求是谁发起的,就能定位到几支脚本:

  • xaf.js
  • abclite-2061-s.js
  • 页面主包 s.d809aa18b51.js

3. 再在脚本里搜关键行为

这一步的思路很直接:

  • document.cookie
  • "ab"
  • localStorage
  • miao.baidu.com/abdr

最后定位到:

  • ab... 是在主包 s.d809aa18b51.js 里写入的
  • beforexaf.js 里更新
  • ab_sr_s53_d91__j47_ka8__y18_s21_ 这组 cookie 由 abclite 这条链刷新

4. 最后再把脚本行为和网络响应对上

这一步不是只看代码,而是把运行时现象对起来:

  • POST /abdr?_o=... 的响应 JSON 里有 data/key_id/sign
  • 对应浏览器里最终刷新的 cookie 正好是:
    • data -> _s53_d91_
    • key_id -> _j47_ka8_
    • sign -> _y18_s21_
  • 响应头 ab-sr 对应 ab_sr

到这里,变量关系链才真正闭合。

六、ab... 到底是什么,为什么和 UA 强绑定

这部分已经直接从前端主包里拆出来了。

1. ab 的 key

key = "ab" + String(hourStartMs).substring(0, 9)

也就是说:

  • key 按小时变化
  • 跨小时后,key 会变

例如:

hourStartMs = 1775660400000
key = ab177566040

2. ab 的 value

raw = md5(navigator.userAgent + "A110A") + Date.now()
value = raw[0] + raw[raw.length - 2] + raw.slice(2, -2) + raw[1] + raw[raw.length - 1]

这就是为什么我们后来确认:

  • UA 不是普通请求头,而是 ab 的直接输入
  • ab 不是静态 cookie,而是每一轮都要重新算

3. Python 复现代码

下面是复现 ab 的最小代码,时间和 cookie 已脱敏:

import hashlib
import time


def build_ab(user_agent, now_ms=None):
    if now_ms is None:
        now_ms = int(time.time() * 1000)
    hour_ms = (now_ms // 3600000) * 3600000
    key = "ab" + str(hour_ms)[:9]
    raw = hashlib.md5((user_agent + "A110A").encode()).hexdigest() + str(now_ms)
    value = raw[0] + raw[-2] + raw[2:-2] + raw[1] + raw[-1]
    return key, value

4. 现场复算证据

真实浏览器会话里某次写入:

cookie_key   = ab177566040
cookie_value = b59ae6443698d3647ba14903fa4753031775661328310

用上面的算法,在同一时刻、同一 UA 下复算:

recomputed_value = b59ae6443698d3647ba14903fa4753031775661328310

两者一致。这一步确认了:

  • ab 不是服务器回的固定值
  • 也不是抓包里某个隐藏字段直接搬过来的
  • 而是前端在本地现算的

七、除了 ab,还有哪些状态真的参与了链路

1. before / um

xaf.js 里,before 的初始形态是:

20$ + app_key + s + Date.now() + 4位随机数

其中:

  • app_key = 5647
  • s 放在 localStorage["s"]

之后 POST https://sofire.baidu.com/data/ua/ab.json 返回一个 id,写进 localStorage["um"],然后:

before = before + um

这一步说明 before 不是固定字符串,而是一个会随着会话状态变化的前置令牌。

2. MIAO_ET

abclite 这支解密后,字段 303 能和浏览器本地 MIAO_ET 对上。

对应关系是:

MIAO_ET = <base>_<expire_ts>
field 303 = <base>

换句话说,MIAO_ET 不是无用缓存,而是参与上报的一部分状态。

3. miao/abdr 返回和 cookie 的映射

这条是通过响应值和最终 cookie 一一对上的:

response.data   -> _s53_d91_
response.key_id -> _j47_ka8_
response.sign   -> _y18_s21_
header ab-sr    -> ab_sr

这也解释了为什么我们后来不再把问题理解成“缺一个 cookie”,而是“状态链没有走完整”。

八、用样本把“真页 / 假页 / 弱结果页”钉死

这一节只看证据,不先下定义。

1. 真页样本:精确企业名

查询:

北京搜狗信息服务有限公司

关键返回:

final_url   = /s?q=北京搜狗信息服务有限公司&t=0
html_length ≈ 282947
title       = 北京搜狗信息服务有限公司 - 爱企查
queryWord   = 北京搜狗信息服务有限公司
queryStr    = 北京搜狗信息服务有限公司
resultList  = 10

2. 真页样本:不完整企业名

查询:

搜狗信息服务

关键返回:

final_url   = /s?q=搜狗信息服务&t=0
title       != ""
queryWord   = 搜狗信息服务
queryStr    = 搜狗信息服务
resultList  > 0

注意:

  • 这是真页
  • 但不是精确命中页
  • 后面业务上应归类为 FUZZY_HIT

这一步非常重要,因为它说明:

“没有精确命中企业名”不等于“是假页”。

3. 假页样本

查询:

北京搜狗信息服务有限公司

异常返回:

final_url   = /s?q=北京搜狗信息服务有限公司&t=0
html_length ≈ 18558
title       = ""
queryWord   = 北京搜狗信息服务有限公司
queryStr    = 城厢
resultList  = 2

这类页的典型问题是:

  • 看起来“搜索成功了”
  • 但服务端把真正查询词换掉了
  • 结果列表也跟输入无关

4. 弱结果页样本

查询:

北京搜狗信息服务有限公司

弱页返回:

title        = ""
queryWord    = 北京搜狗信息服务有限公司
queryStr     = null
resultList   = 0
absorbed     = 5

这类页不是验证码页,也不是严格意义上的假页,但也不能拿来做正式候选。

5. 真实无结果页样本

查询:

火星不存在企业名称测试有限公司

在真实浏览器态里,曾观测到:

title       != ""
queryWord   = 火星不存在企业名称测试有限公司
queryStr    = 火星不存在企业名称测试有限公司
resultList  = 0
absorbed    = 0

这说明:

  • resultList == 0 不等于反爬
  • 必须先确认“这页是真实搜索页”

九、如何验证“ab 失效后能不能被识别出来”

这里的目标不是证明“所有异常都来自 ab”,而是验证:

当 ab 被故意改坏时,我们的页面分类逻辑能不能稳定识别出“这不是真页”。

1. 测试思路

固定 3 个控制查询:

  • 北京搜狗信息服务有限公司
  • 搜狗信息服务
  • 上海奇安信

这 3 个查询在有效状态下应分别返回:

  • EXACT_HIT
  • FUZZY_HIT
  • FUZZY_HIT

然后构造多组边界条件:

  • 正确 ab
  • ab
  • 随机 ab
  • 截断 ab
  • 改错 1 位 ab
  • 上一个小时的 ab
  • 不带 ab
  • UA
  • BAIDUID
  • 默认代理污染

2. 验证代码

下面是脱敏后的核心代码:

import hashlib
import json
import re
import time
import urllib.parse
import requests

UA_GOOD = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
UA_BAD = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"

CONTROL_QUERIES = [
    "北京搜狗信息服务有限公司",
    "搜狗信息服务",
    "上海奇安信",
]

def build_ab(user_agent, now_ms=None, hour_offset=0, mode="valid"):
    if now_ms is None:
        now_ms = int(time.time() * 1000)
    hour_ms = ((now_ms // 3600000) + hour_offset) * 3600000
    key = "ab" + str(hour_ms)[:9]
    raw = hashlib.md5((user_agent + "A110A").encode()).hexdigest() + str(now_ms)
    good = raw[0] + raw[-2] + raw[2:-2] + raw[1] + raw[-1]

    if mode == "valid":
        value = good
    elif mode == "empty":
        value = ""
    elif mode == "random":
        value = hashlib.md5((str(now_ms) + "x").encode()).hexdigest() + str(now_ms)
    elif mode == "truncated":
        value = good[:-8]
    elif mode == "one_char_off":
        value = good[:-1] + ("0" if good[-1] != "0" else "1")
    else:
        raise ValueError(mode)

    return key, value

def extract_page_data(html):
    m = re.search(r'window\\.pageData\\s*=\\s*(\\{.*?\\})\\s*;\\s*window\\.isSpider', html, re.S)
    if not m:
        return None
    return json.loads(m.group(1))

def classify(input_name, final_url, html):
    if "wappass.baidu.com/static/captcha" in final_url or "百度安全验证" in html:
        return "CAPTCHA"
    if "/acount/accessrestriction" in final_url:
        return "ACCESS_RESTRICTION"

    pd = extract_page_data(html)
    if not isinstance(pd, dict):
        return "INVALID_RESPONSE"

    result = pd.get("result") if isinstance(pd.get("result"), dict) else {}
    qword = pd.get("queryWord")
    qstr = result.get("queryStr")
    title_m = re.search(r'<title>(.*?)</title>', html, re.S | re.I)
    title = title_m.group(1).strip() if title_m else ""
    meta_present = bool(result.get("title") or result.get("keywords") or result.get("description"))
    result_list = result.get("resultList") or []
    absorbed = result.get("absorbed") or []

    if qword == input_name and len(result_list) == 0 and len(absorbed) > 0 and title == "":
        return "WEAK_RESULT_PAGE"
    if qword != input_name:
        return "INVALID_RESPONSE"
    if qstr != input_name:
        return "FAKE_RESULT_PAGE"
    if not meta_present or title == "":
        return "FAKE_RESULT_PAGE"
    if len(result_list) == 0 and len(absorbed) == 0:
        return "NO_HIT"
    return "REAL_PAGE"

3. 结果怎么解读

这轮矩阵测试的核心结论是:

  • 正确 BAIDUID + 当前小时合法 ab + 正确 UA,3 个控制查询稳定是真页
  • ab 改坏后,3 个控制查询会一起变成非真页面,当前主要表现为 CAPTCHA
  • UA 也会触发同样的非真页面
  • 默认代理污染会把结果打成 ACCESS_RESTRICTION
  • BAIDUID 也会失败

所以后续代码里应该这样理解:

  • 页面分类器可以稳定判断“当前响应不是真页”
  • 但不能只靠这个结果就断言“唯一原因是 ab 失效”

更准确的说法是:

当前请求状态失效。

至于是 abUA、代理,还是别的前置状态,要再分层排查。

十、最后落到工程实现:最简、可维护的判断方案

如果目标只是:

在代码里判断这次响应是不是真实搜索结果页

那么没必要把所有业务含义都揉进一个函数。建议拆成两层。

第一层:页面真伪判断

只回答一个问题:

这是不是爱企查真实搜索结果页

最简规则:

  1. final_url 包含 captcha -> CAPTCHA
  2. final_url 包含 accessrestriction -> ACCESS_RESTRICTION
  3. 解析不到 window.pageData -> INVALID_RESPONSE
  4. queryWord != 输入 -> INVALID_RESPONSE
  5. resultList == 0absorbed > 0title == "" -> WEAK_RESULT_PAGE
  6. queryStr != 输入 -> FAKE_RESULT_PAGE
  7. title == ""result.title / keywords / description 全空 -> FAKE_RESULT_PAGE
  8. 其余情况 -> REAL_PAGE

第二层:业务命中判断

只在 REAL_PAGE 的前提下再分:

  • EXACT_HIT
    • resultList > 0
    • titleName 或清洗后的 entName 精确等于输入
  • FUZZY_HIT
    • resultList > 0
    • 但无精确命中
  • NO_HIT
    • resultList == 0
    • absorbed == 0

为什么这样最稳

因为代码侧无法可靠判断输入的是:

  • 全称
  • 简称
  • 不完整公司名

所以:

  • “有没有精确命中公司名”不能参与真伪判断
  • 它只能参与业务命中判断

十一、结论

这次分析最终确认了三件事。

第一,爱企查搜索链已经不能按“带 cookie 请求一个 URL”理解。
真实搜索前,浏览器会跑一条状态链,/s?q=... 只是最后一步。

第二,最危险的问题不是验证码,而是假页。
它也会返回 200、也带 pageData、也可能带 resultList,但内容已经被替换。

第三,工程上最应该先做的不是“猜输入是不是完整公司名”,而是:

先判断页面是不是真页,再谈业务命中。

这一步做好了,后续无论你是重算 ab、刷新状态,还是提示人工介入,逻辑都会清晰很多。

附:本文中用到的脱敏样例

1. 最小请求 cookie 形态

BAIDUID=878615B6D355A313D7292E318C53FE4F:FG=1;
ab177566040=b59ae6443698d3647ba14903fa4753031775661328310;

2. 历史测试中出现过的 BDUSS

BDUSS=dxYjR5QUR5VmpBRWZtVTFHQmNqTEZN...mW

3. 成功请求和异常请求的最小对照

真页:

URL        = /s?q=北京搜狗信息服务有限公司&t=0
title      = 北京搜狗信息服务有限公司 - 爱企查
queryWord  = 北京搜狗信息服务有限公司
queryStr   = 北京搜狗信息服务有限公司
resultList = 10

假页:

URL        = /s?q=北京搜狗信息服务有限公司&t=0
title      = ""
queryWord  = 北京搜狗信息服务有限公司
queryStr   = 城厢
resultList = 2

弱结果页:

title      = ""
queryWord  = 北京搜狗信息服务有限公司
queryStr   = null
resultList = 0
absorbed   = 5

4. miao/abdr 返回和 cookie 落盘对照

这类证据非常关键,因为它证明前置状态链不是猜出来的,而是可以直接对上的。

一次真实搜索里,POST https://miao.baidu.com/abdr?_o=https://aiqicha.baidu.com 的响应里出现过:

data   = 0715a9...df376
key_id = 57
sign   = 5232773c
header ab-sr = 1.0.1_NGQ4...

随后浏览器里的 cookie 刷新成:

_s53_d91_ = 0715a9...df376
_j47_ka8_ = 57
_y18_s21_ = 5232773c
ab_sr     = 1.0.1_NGQ4...

这一步把“响应字段”和“浏览器最终状态”直接串起来了。

5. 已确认的前端常量

这些不是文章主结论,但对复盘和二次验证很有用,这里单独列出来。

xaf.js 这支:

app_key = 5647
key_id  = 3
key     = 0fc0d47746054969
iv      = 636014d173e04409
algorithm = AES-CBC + PKCS7

abclite 这支:

key_id  = 3a8632cf2e8642f7
key     = EB6002A060224F94
iv      = 636014d173e04409
algorithm = AES-CBC + PKCS7

这些常量的作用不是直接拿来判真页,而是帮助确认:

  • xaf.jsabclite 确实在生成、刷新前置状态
  • before / um / MIAO_ET / ab_sr / _s53 / _j47 / _y18 不是互相独立的散点
  • 它们属于同一条浏览器挑战状态链
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐