家里有位“海鲜采购总管”,你就明白我为什么折腾这个项目了。我家那位隔三差五就念叨同一句话:“基围虾今天又贵了,你到底买不买?”等来等去,要么涨价,要么卖完。与其碰运气捡漏,不如自己动手给盒马的价格接口做个监控:抓包拿到 App 里的真实 API,用 Python 定时把海鲜价格抓下来存库,一旦降到心理价位就提醒我下单。整个项目做完,我顺手把抓包分析、接口复刻、数据入库、定时调度这一整套流程总结成这篇文章。如果你有 Python 基础,知道 requests 怎么用,又想知道别人是怎么从 App 里抓数据的,那这篇文章应该能让你少走不少弯路。
1. 项目立项:为什么选“抓包 API”而不是“页面爬虫”
1.1 需求场景:等打折等到心累
先说清楚我要解决的问题。盒马的商品价格不是固定不变的,受进货、促销、库存影响挺大,尤其是海鲜这类短保品,早上和晚上的价格可能差出一截。我想做的不是看一次价格,而是持续观察某一个商品的 price 波动:哪种虾什么时段最便宜、周几会有折扣、降价的时候能不能及时收到提醒。
把需求拆一下,其实就三条:拿到某个商品在某个门店的实时价格、持续记录价格变化、价格低于阈值时通知我。听起来不复杂,但怎么“拿到实时价格”这一步,恰恰是大多数人卡住的地方。
1.2 为什么选“抓包 API”而不是“页面爬虫”
很多人第一反应是直接用 requests 爬盒马的商品页面,然后去解析 HTML。这个方案我一开始也试过,结果很尴尬:盒马的 H5 页面数据是异步加载的,页面源代码里根本没有价格数字,你得去找它背后的数据接口。既然要找数据接口,那不如直接从根上拿——用抓包工具看 App 到底访问了哪个 URL、传了什么参数、返回了什么结构。把 App 和服务器之间的对话摸清楚,然后用 Python 模拟它,这种思路比解析 HTML 稳健得多。
还有一个原因:App 端接口的数据结构是 JSON,解析起来比嵌在 HTML 里的标签干净太多了,字段明确,甚至有现成的商品名、价格、规格、库存状态。拿到 JSON 之后,直接人脸识别级别地对着字段取数就行,逻辑简单,出错概率也低。
1.3 整体方案和落地顺序
我最后定的技术方案大致如下:
- 抓包工具:Charles,负责看 HTTPS 请求和响应内容
- 抓包方式:手机和电脑连同一 WiFi,手机设置 HTTP 代理,Charles 帮我们解密 HTTPS 流量
- 分析重点:找到价格接口的 URL、请求参数、必要的 Header 和 Cookie
- 爬虫框架:不引框架,直接 requests + Session,轻量、可控、好排错
- 数据存储:SQLite + SQLAlchemy,个人项目不需要上 MySQL 那么重
- 定时调度:APScheduler,每隔半小时跑一次,控制频率,稳步监控
- 提醒:控制台打日志为主,可选接一个群机器人 Webhook
整个落地过程,我按“抓包—分析—复刻—落库—调度”的顺序来讲,每一步都会标出我当时踩坑的点。
2. 抓包环境搭建:把盒马 App 的流量看穿
2.1 工具选型:Charles 还是 Fiddler
抓包工具选哪个,网上吵得挺凶。我自己 Windows 和 Mac 都用过,简单说下感受:
| 工具 | 平台 | 上手难度 | 核心优势 |
|---|---|---|---|
| Charles | Windows / macOS | 低 | 界面直观,Host 过滤方便,支持全量历史搜索 |
| Fiddler | Windows 为主 | 中 | 免费,插件多,能写脚本断点改包 |
| mitmproxy | 全平台 | 中高 | Python 脚本控制,适合把抓包过程自动化 |
日常排查我用 Charles,因为它的会话列表里能直接按域名过滤,搜索历史请求也方便——这在后面“找接口”环节帮了大忙。如果你只用 Windows,Fiddler 也完全够用,思路都是互通的,下面讲的证书和代理设置大同小异。
2.2 HTTPS 抓包配置全流程
抓包的核心思路其实一句话:让手机的流量绕到我们的电脑上转一圈,再把 HTTPS 的流量解密成明文。我一个一个步骤拆开,你照着做就行。
第一步,启动 Charles,记住电脑的局域网 IP,一般是Settings – Network里能看到;Charles 的默认代理端口是8888。
第二步,把手机和电脑连到同一个 WiFi,然后在手机的 WiFi 设置里选择“手动代理”,填入电脑 IP 和端口 8888。这一步之后,手机访问网页、打开 App 的流量都会经过 Charles,但这时候 HTTPS 还是加密的,你只能看到一堆CONNECT记录,看不到具体内容。
第三步,安装并信任 Charles 的根证书。手机浏览器访问chls.pro/ssl下载证书并安装。iPhone 装完证书还要去设置 – 通用 – 关于本机 – 证书信任设置里手动开启信任,不然抓了一天全是乱码。这里必须多说一句:证书不信任是新手遇到最多的拦路虎,只要看到 HTTPS 请求体是乱码或者“No request received”,八成就是证书没信任成功。
第四步,在 Charles 的Proxy – SSL Proxying Settings里勾选开启,并添加*:443表示解密所有 HTTPS 流量。这一步不做,只会看到加密流量,一样白搭。
第五步,验证解密是否成功:在手机上随便打开一个网页,回到 Charles 看请求列表,如果能看到具体的 URL 路径、Header 和返回内容,说明证书装好了。如果只看到443端口的 CONNECT 但没有展开内容,说明 SSL Proxying 没生效。
提示:Android 7.0 及以上版本的 App 默认不完全信任用户安装的证书,抓包时可能出现部分请求是黑盒、部分请求能解析的情况。盒马 App 我实测下来整体能看,但如果你以后抓其他加固过的 App 碰到类似情况,需要的是研究如何把证书装进系统证书库,这部分属于另一门课了,不在本文范围。
2.3 关键一步:在几十个请求里捞出价格 API
证书配好之后,手机打开盒马 App,随便进一个海鲜商品的详情页,然后回 Charles,你会看到海量的请求:埋点、首页推荐、购物车状态、用户信息……一眼看过去全是噪音。这里我分享三个经验,能迅速从噪音里捞出目标接口:
第一,用底部 Filter 栏直接过滤域名。盒马的接口域名通常是freshhema.com或hemaos.com这类,你在 Filter 里输入hema或者fresh,列表会瞬间干净很多。App 的请求很多,但目标商品的详情请求往往集中在某个固定的 API 域名下。
第二,用内容搜索做定位。Charles 的Edit – Find可以搜索历史流量里的文本内容。你打开商品详情页之前先想好一个特征词,比如海鲜名称或者“price”这个字段名,然后在 Charles 里直接搜。搜到的那条请求,往往就是你要找的接口。
第三,反向定位。找到一条返回数据里明显包含商品信息的 JSON 响应,点进去看对应的请求地址。比如我这边看到某个响应里包含skuId、price、originPrice,那这个接口基本就是详情页价格接口。你要做的,是把这条请求完整地“抄”下来,包括 URL、请求方法、请求头、请求体。
我当时抓到的是POST /gw/buy/v1/detail之类的地址,请求体里带着storeId(门店 ID)、skuId(商品 ID)、channel(渠道标识)等参数。结构和具体路径不同版本会有变化,但找到它的方法是不变的:过滤域名、搜特征词、定位响应里带价格字段的那条请求。
3. 核心 API 分析与请求复刻
3.1 参数逐个拆解:哪些能改,哪些不能动
抓到接口之后,别急着写代码,先把请求体和响应结构看懂。以我当时抓到的详情接口为例,请求体大致的 JSON 长这样:
{ "storeId": "9", "skuId": "1000000123", "channel": "app", "variable": {} }这里面最核心的是storeId和skuId。storeId决定价格来自哪个门店,你人在哪个城市,就填对应门店的 ID,这个 ID 可以在 App 首页接口返回的门店列表里找到;skuId就是商品 ID,每个商品一个,稳定不变,适合作为监控的主键。
channel这个参数一般别去动它,它标识了请求来自 App 端。有些接口会把 Web 端和 App 端的请求区分对待,如果你把channel改成别的值,返回的数据结构可能是另一套,平白增加解析成本。
再看响应,一个典型的返回包长这样:
{ "code": 0, "msg": "success", "data": { "sku": { "skuId": "1000000123", "name": "活基围虾", "price": 9900, "originPrice": 12900, "stock": 50, "spec": "500g/盒" } } }这里有个大坑必须提醒你:很多生鲜电商的价格字段是“分”,不是“元”。9900对应的是99.00元,12900对应129.00元。你要是图省事直接把数字存进去,后面做价格对比时会发现差了 100 倍。我建议在解析层统一除以 100 并保留两位小数再入库,后面所有比较、展示就都不会出问题。
3.2 签名和请求头的处理思路
大型 App 的接口通常都会带签名参数或者校验 Header,盒马这边我测试时发现,详情接口的校验相对宽松,直接带上登录后的 Cookie,把skuId换掉,多数情况下能正常返回数据。但这不代表你也可以忽略签名和 Header,这里我按“从轻到重”的顺序讲一下处理思路。
第一优先看 Header。完整的User-Agent、Content-Type、Accept必须和 App 发的保持一致,尤其是 UA。UA 里往往带 App 的版本号、渠道、机型信息,服务器经常用它来判断请求是不是来自真机。我是直接从抓包记录里复制 UA 出来用的,这个习惯建议你也保留。
第二看 Cookie。App 登录后的会话数据会体现在 Cookie 里,详情接口如果返回“未登录”或者“需要授权”,大概率是 Cookie 过期了。解决办法很简单:重新打开盒马 App 登录一次,然后回到 Charles 复制新的 Cookie。这个操作看起来笨,但对个人监控项目来说是最稳的。
第三再看签名。如果接口里有类似sign、_sign这样的参数,说明请求参数是参与签名的。常见做法是把所有参数按字典序排列后拼起来,加上一个固定密钥,做 MD5 或者 HMAC。这个密钥藏在 App 的代码里,能不能找到取决于逆向经验。但有意思的是,很多 App 的签名只校验“参数被篡改”,不校验“请求是否为同一时刻发起的”,所以在签名失效之前,你可以按固定参数模板复用抓到的 sign。换句话说,只要你的storeId和skuId不变,原先抓到的那一版签名可能还能用一段时间。当然,如果某天你的脚本突然报签名错误,别纠结,回到 Charles 重新抓一次最新的请求,更新一下参数模板就行。
注意事项:不同版本、不同接口的校验强度不一样。本文讲的是学习和技术验证思路,拿自己常用的账号监控几个商品的价格属于个人合理使用范畴,这一点心里要有数:不要去写大规模的分布式抓取,也不要用同理绕过会员体系拿你的账号没权限的数据。
3.3 用 Python 还原一个可复用的请求会话
分析清楚之后,写 Python 就是水到渠成的事了。我用requests.Session来保存 Cookie 和默认 Header,这样每次请求都会带上统一的 UA 和 Cookie,不用每个请求单独传一遍:
# -*- coding: utf-8 -*- import time from datetime import datetime import requests API_URL = "https://api.freshhema.com/gw/buy/v1/detail" STORE_ID = "9" # 换成你常用门店的 storeId HEADERS = { "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Hema/2.3.0", "Content-Type": "application/json", "Accept": "application/json", } COOKIE = "换成你从 Charles 里复制的 Cookie" session = requests.Session() session.headers.update(HEADERS) session.cookies.update({"cookie 字段": COOKIE}) # 按实际抓到的 Cookie 内容填 def fetch_sku(sku_id: str) -> dict: payload = { "storeId": STORE_ID, "skuId": sku_id, "channel": "app", "variable": {}, } resp = session.post(API_URL, json=payload, timeout=10) resp.raise_for_status() result = resp.json() if result.get("code") != 0: raise RuntimeError(f"接口返回异常: {result}") sku = result["data"]["sku"] return { "sku_id": sku_id, "name": sku["name"], "price": round(sku["price"] / 100, 2), # 分转元 "origin_price": round(sku.get("originPrice", 0) / 100, 2), "stock": sku.get("stock", 0), "captured_at": datetime.now(), } if __name__ == "__main__": sku_list = ["1000000123", "1000000456"] # 换成你想监控的 skuId for sku in sku_list: try: record = fetch_sku(sku) print(record) except Exception as exc: print(f"[错误] {sku} 抓取失败: {exc}") time.sleep(2)这套代码的核心价值在于把“抓包拿到的东西”变成“一个可以反复调用的函数”。后面不管是入库、定时任务,还是做提醒,都建立在fetch_sku返回的这条记录之上。
4. 数据落库与定时监控上线
4.1 用 SQLAlchemy 把价格记录存进 SQLite
自己做个监控,数据库选 SQLite 就够了,它就是一个本地文件,不需要装服务端,随拿随用。搭配 SQLAlchemy 的好处是,你不用手写原生 SQL,定义好表结构之后,增删改查都是对象操作,后面即使你换到 MySQL、PostgreSQL,代码改动也很小。
我建的表结构核心字段就这么几个:sku_id、name、price、origin_price、stock、captured_at。价格必须记成浮点数,时间必须带时分秒,这样后面才能画出一条完整的价格走势。
# model.py from datetime import datetime from sqlalchemy import create_engine, Column, String, Float, DateTime, Integer from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker Base = declarative_base() class PriceRecord(Base): __tablename__ = "price_records" id = Column(Integer, primary_key=True, autoincrement=True) sku_id = Column(String(64), index=True) name = Column(String(128)) price = Column(Float) origin_price = Column(Float, default=0) stock = Column(Integer, default=0) captured_at = Column(DateTime, default=datetime.now) engine = create_engine("sqlite:///hema_prices.db") Base.metadata.create_all(engine) SessionLocal = sessionmaker(bind=engine)插入记录也非常直白:
from model import SessionLocal, PriceRecord def save_record(record: dict) -> None: db = SessionLocal() try: item = PriceRecord(**record) db.add(item) db.commit() except Exception: db.rollback() raise finally: db.close()价格记录是只写不改的,每次抓取都是一条新记录,因此不用纠结“要不要先查一遍再更新”。等到你想看历史最低价、想画折线图的时候,直接按sku_id查时间范围内的所有记录就行。
4.2 调度策略:既要盯得住,又要克制点
定时任务的方案有很多,例如本机 crontab、Windows 计划任务,或者 Python 进程内调度。我个人在个人项目里更喜欢 APScheduler,因为写起来就是几行代码,不用去管系统层面的配置,也能在进程里统一处理日志和异常。
# schedule.py from apscheduler.schedulers.blocking import BlockingScheduler from monitor import monitor_once def main(): scheduler = BlockingScheduler() # 每 30 分钟跑一次,随机延时几秒,避免请求时间高度规律 scheduler.add_job(monitor_once, "interval", minutes=30, jitter=10) scheduler.start() if __name__ == "__main__": main()这里要特别说下频率控制。监控的目的是“捕捉降价”,不是“给盒马服务器做压力测试”。半小时跑一次、一次只查几个固定的 SKU,已经足够覆盖大部分价格变化。你要是改成每分钟拉一次,或者把全店商品都拉下来,那基本就是在给自己找封号的麻烦,请求越规律越容易被识别。jitter=10这个参数能让每次执行时间有 10 秒以内的随机偏移,算是一个简单又实用的“去规律化”手段。
另外,我建议在monitor_once里给单次请求设置超时和最多两次重试,连续失败三次就跳过本轮,并打印告警。监控脚本挂了不可怕,可怕的是挂了之后你还不知道,价格降了又涨回去你还在傻等。
4.3 不花钱的“预警系统”:降价提醒怎么发
监控数据存下来之后,最关键的一步是“降价提醒”。如果每次都要打开数据库查价格,那这监控就没什么意义了。我加了一个很朴素的判断逻辑:把当前价格和指定时间段内的最低价做对比,一旦当前价低于历史最低价的一定比例,就触发提醒。
def check_low_price(record, threshold_ratio=0.9): db = SessionLocal() try: # 取这个 SKU 在最近 7 天内的最低价 since = datetime.now() - timedelta(days=7) rows = ( db.query(PriceRecord.price) .filter( PriceRecord.sku_id == record["sku_id"], PriceRecord.captured_at >= since, ) .all() ) if not rows: return False min_price = min(row[0] for row in rows) # 当前价 <= 历史最低的 90%,说明价格进入低位区间 return record["price"] <= min_price * threshold_ratio finally: db.close()触发提醒之后怎么通知自己?新手最容易忽略这一步。我个人推荐用企业微信群机器人或者 Server酱这类工具,本质就是往一个 Webhook 地址 POST 一段 JSON,手机上就能收到推送。如果你只是想本地验证效果,先print一段醒目的文字也完全够用,等确认整个流程稳定了再接上消息推送。
提醒文案别光发一个价格,我一般会把商品名、当前价、历史最低价、门店信息都带上,方便自己判断要不要冲到 App 里去下单。
5. 实操中踩过的坑与排查实录
5.1 常见问题速查表
这几天跑下来,踩坑是少不了的。我把最常见的现象、原因、处理方式整理成一个速查表,你要是碰到类似问题,直接对着表排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| HTTPS 请求全部乱码 | 证书没安装或没信任 | 按 2.2 节重装证书并开启 SSL Proxying |
| Charles 里只有 CONNECT,没有具体请求 | SSL Proxying 没开启 | 在Proxy – SSL Proxying Settings添加*:443 |
| API 返回未登录/授权失败 | Cookie 过期 | 重新打开 App 登录,重新复制 Cookie |
| 请求报 403 或频繁超时 | 请求频率过高被限制 | 拉大请求间隔,降低单次请求 SKU 数量 |
| 价格数字不对,动不动差 100 倍 | 价格单位是“分” | 解析时统一除以 100 再入库 |
| 某天脚本突然报 Sign 错误 | App 升级或签名校验收紧 | 重新抓包,复制最新的请求头和参数模板 |
| 手机上部分 App 抓不到包 | Android 7+ 证书信任限制 | 考虑换低版本设备或研究系统证书安装方案 |
这里面我最想强调的是“Cookie 过期”这个问题。你可能会遇到一种情况:第一天跑得好好的,第三天突然全部失败。别慌,先别急着改代码,打开 Charles 看看 App 现在的请求和你脚本里的 Header 有什么区别。90% 的情况是登录态过期了,重新登录一次就能解决。
5.2 那些代码之外的忠告
最后说几句务实的话。这种抓包 + 接口复刻的思路,不只是盒马能用,它适用于绝大多数“看着像黑盒、实际上只是一个 HTTPS 接口”的 App。你已经掌握的是一套方法论,换一个目标,流程几乎一模一样:配好代理、解开 HTTPS、过滤域名、搜索特征字段、复刻请求、落库调度。这套方法论的价值,远比某个具体接口的地址大得多。
但也正因为这套方法通用,你更要知道边界在哪里。我建议你做到下面几点:只监控自己账号有权限看的商品,不批量拉取全量商品;只做个人价格监控,不把数据往外卖;不要在此基础上做自动下单抢购之类的操作,那既违反用户协议,也会破坏正常用户的购物体验。技术本身是中性的,用它解决自己生活中的小痛点,顺手提升一点效率,是我觉得最舒服的用法。
这几天监控下来,我最大的感受是:比起“把海鲜价格抓下来”,更难的是“坚持每天看一遍结果”。脚本写完之后,真正让我受益的是自动化和提醒机制,它们把一件需要人盯的小事,变成了机器替你盯的系统。最后我等的基围虾也确实降到了心理价位,那天晚上我打开手机下了单,这个项目对我来说就算圆满收官了。如果你也想监控点什么,别急着羡慕别人现成的脚本,照着上面的流程自己抓一次包、跑一个定时任务,这份“自己亲手搞定”的踏实感,才是最有意思的部分。