☰
Fiddler抓包与Python复刻APP登录:JSON解析及时间戳翻页
2026/11/2 6:58:59 网站建设 项目流程

简介:围绕手机APP数据传输的抓取与解析,这份PDF文档以超级课程表为实例,完整走通从抓包定位到数据落地的采集链路,适合具备Python基础、想入门网络爬虫的开发者与安全爱好者阅读。内容涵盖用Fiddler捕获APP请求、锁定登录接口与表单字段、以urllib2构造携带Header和Cookie的HTTP请求完成模拟登录,再到解析返回的JSON数据、按时间戳翻页实现话题的无限加载,代码与说明一一对应,并在Python2.7环境下实测通过。整包仅1个PDF文件,约143KB,篇幅紧凑、聚焦实战。目前已有368人学习。读者可借此掌握请求头构造、会话保持、JSON字段提取与分页控制等通用技巧,理解动态接口数据采集的完整设计流程,文中还提示了反爬适配与合规使用的边界,便于迁移到其他平台的信息采集任务。

1. 手机 APP 的数据不在 HTML 里:先认清这件事再动手

很多人第一次做移动端数据抓取,习惯性地把网页那套搬过来:requests拿页面、BeautifulSoup找节点。放到 APP 上直接失效——你拿到的不是渲染好的 DOM,而是一串 JSON,甚至是加密过的字节流。这份资源的全链路是Fiddler抓包定位接口 → 复刻 HTTP 请求头与表单完成登录 →CookieJar维持会话 →json解析并按时间戳游标翻页,环境和代码都跑在 Python 2.7 上。适合有 Python 基础、想搞清楚 APP 接口抓取与 JSON 解析到底怎么落地的研发。真正的门槛不在语法,而在细节:header 少一个字段、Content-Length差一个字节,返回的就是登录失败。

2. 抓包定位接口:Fiddler 证书、代理与请求还原

移动端抓取的第一步永远是「先看见」,而不是「先写代码」。APP 的请求在手机上发出,你的电脑默认看不见,必须让流量绕一段路。Fiddler 在这里扮演的是中间人的角色:手机把 HTTP 请求发给 Fiddler,Fiddler 记录后再转发给服务端,响应原路返回。这个过程对 APP 是无感的,只要代理配置正确,它以为自己还在直连。

2.1 把手机流量引到电脑上

先确认电脑和手机在同一网段,Fiddler 启动后在Tools → Options → Connections里允许远程连接,默认端口 8888。手机端进入当前 Wi-Fi 的高级设置,代理类型选手动,主机名填电脑的局域网 IP,端口填 8888。设置完成后随手打开一个网页,Fiddler 左侧的会话列表应该开始刷记录。

只到这一步,你只能看到 HTTP 明文。目标 APP 的关键接口大概率走 HTTPS,此时会话列表里会出现大量Tunnel to的条目,点进去只有握手信息,看不到 body。解决办法是让手机信任 Fiddler 的根证书:在手机浏览器访问http://<电脑IP>:8888,下载证书并安装,Android 需要在「安全 → 安装证书」里手动导入,iOS 则要在「关于本机 → 证书信任设置」里把开关打开。装完之后再刷新 APP,Tunnel to会变成正常的 HTTPS 会话,右侧 Inspectors 里能看到完整的请求体和响应体。

提示:证书安装后仍然看不到明文,先检查 APP 是否做了证书校验。部分应用会拒绝非系统证书,这种情况下抓包这条路走不通,不要在这上面耗时间。

代理开着的时候,手机可能连不上网或者部分应用提示网络异常,这是正常的——请求经过了 Fiddler 但握手没通过。抓完包记得把代理关掉,否则日常使用会一直受影响。

2.2 从几百条请求里挑出登录接口

打开 APP 完成一次登录,然后在 Fiddler 里按Ctrl+F搜关键词。搜什么?搜业务字段名比搜 URL 更准,比如password、account、login、token。找到候选会话后看三样东西:请求方法是不是 POST、Content-Type是不是application/x-www-form-urlencoded、响应体里有没有用户信息。

以这份资源里的场景为例,登录接口长这样:

项目抓到的值说明
请求地址http://203.0.113.10/V2/StudentSkip/loginCheckV4.action端口 80,全程 HTTP
方法POST表单提交
Content-Typeapplication/x-www-form-urlencoded; charset=UTF-8表单编码
User-AgentDalvik/1.6.0 (Linux; U; Android 4.1.1; M040 Build/JRO03H)安卓 Dalvik 虚拟机标识
Host203.0.113.10与请求地址一致
表单字段phoneBrand、platform、deviceCode、account、password、channel、phoneModel、versionNumber八个字段,账号口令已是密文

这里有个很容易被忽略的点:account和password在抓包里就是加密后的十六进制串,长度固定。你不需要逆向它的加密算法,直接把密文原样搬运即可——登录接口校验的是这串密文,不是明文。这也是这类接口能被复刻的最主要原因。

2.3 把抓到的请求翻译成 Python 结构

抓包软件一般自带「复制为 cURL」,但那份东西粘到 Python 里还得改造。我一般直接照着 Inspectors 里的 Raw 视图手写,一个字段一个字段对,比改自动生成的代码靠谱。翻译的原则只有一条:抓包里出现的请求头,除Cookie外全部带上;抓包里的表单体,一个字段不落。

# 先用 curl 验证接口能通,能通再上 Python # --data 后面就是抓包复制的表单体,注意单引号包裹 curl -i 'http://203.0.113.10/V2/StudentSkip/loginCheckV4.action' \ -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \ -H 'User-Agent: Dalvik/1.6.0 (Linux; U; Android 4.1.1; M040 Build/JRO03H)' \ -H 'Host: 203.0.113.10' \ -H 'Connection: Keep-Alive' \ -H 'Accept-Encoding: gzip' \ --data 'phoneBrand=Meizu&platform=1&deviceCode=<设备码>&account=<密文账号>&phoneVersion=16&password=<密文口令>&channel=MXMarket&phoneModel=M040&versionNumber=7.2.1&'

这一步的价值在于把「网络问题」和「代码问题」切开。curl 能返回用户信息 JSON,说明接口、密文、请求头三样都对,剩下的只是 Python 怎么拼;curl 返回登录错误,那问题一定出在你搬运的某个字段上,跟后面的代码逻辑无关。我见过太多人跳过这一步,直接在 Python 里调半天,最后发现是 UA 写错了。

--data后面的内容要注意两点。一是结尾那个&抓包里带着就带着,服务端一般能容忍,但你自己拼字符串时最好保持一致;二是字段顺序在绝大多数表单接口里无所谓,但如果遇到签名校验,顺序就必须和抓包完全一致。判断方法很简单:把两个字段对调再请求一次,还成功就是无所谓的。

3. 复刻一次 APP 登录:urllib2 + CookieJar 的 header 与表单细节

接口通了之后,Python 这边其实只有三件事:拼请求、管会话、读响应。这份资源用的是标准库urllib2加cookielib,没有第三方依赖,在 Python 2.7 环境下开箱即用。放到 Python 3 里就得换成urllib.request和http.cookiejar,代码结构一样,只是模块路径变了。

3.1 为什么必须用 CookieJar 而不是手抄 Cookie

APP 登录成功后,服务端会在响应头里下发Set-Cookie,后续的话题接口靠这个 Cookie 识别身份。最省事的做法当然是抓包时把 Cookie 抄下来硬编码进去,但它有时效——短则几十分钟,长则几天,一旦过期脚本就全盘返回未登录。

CookieJar干的事就是自动接管这个过程:绑到opener上之后,任何一次响应里的Set-Cookie都会被自动存进 jar,后续请求自动带上。你的代码里完全不用出现 Cookie 字符串,也不用关心它什么时候刷新。这在需要长时间运行或者反复登录的场景里几乎是刚需。

3.2 构造登录请求并校验返回

# -*- coding: utf-8 -*- # 运行环境:Python 2.7,urllib2 与 cookielib 均为标准库 import urllib2 from cookielib import CookieJar import json LOGIN_URL = 'http://203.0.113.10/V2/StudentSkip/loginCheckV4.action' # 这些 header 不是可选项,是"身份证"。缺 Host、缺 UA 都可能被判为非法客户端 HEADERS = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'User-Agent': 'Dalvik/1.6.0 (Linux; U; Android 4.1.1; M040 Build/JRO03H)', 'Host': '203.0.113.10', 'Connection': 'Keep-Alive', 'Accept-Encoding': 'gzip', } # 表单体:account / password 抓包时就是密文,原样搬运,不需要解密 LOGIN_BODY = ( 'phoneBrand=Meizu&platform=1&deviceCode=<设备码>&' 'account=<密文账号>&phoneVersion=16&' 'password=<密文口令>&channel=MXMarket&' 'phoneModel=M040&versionNumber=7.2.1&' ) # Content-Length 必须等于 body 的实际字节数,写死一个数字是最常见的翻车点 HEADERS['Content-Length'] = str(len(LOGIN_BODY)) cookie_jar = CookieJar() opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookie_jar)) req = urllib2.Request(LOGIN_URL, LOGIN_BODY, HEADERS) resp = opener.open(req) raw = resp.read() print 'HTTP status:', resp.getcode() print 'Cookie count:', len(cookie_jar) try: payload = json.loads(raw) print 'login ok:', bool(payload.get('data')) except ValueError: print 'not json, first 200 bytes:', raw[:200]

代码里有几处是刻意这么写的。

HEADERS['Content-Length']用len(LOGIN_BODY)动态计算,而不是照抄抓包里的 207。抓包里的长度对应的是那条特定的表单体,你只要改了设备码、换了账号,长度立刻变。长度对不上时,服务端可能直接截断请求体,表现出来就是「账号密码明明没错却登录失败」,这个坑我在第一次复现时就踩过。

opener只创建一次,登录和后续的话题请求共用同一个。这是CookieJar能生效的前提——新建一个opener就相当于换了个浏览器,之前的 Cookie 全丢。

json.loads包在try里,是因为登录失败时服务端可能返回一段 HTML 错误页而不是 JSON,直接json.loads会抛异常把整个脚本打断。打印前 200 字节足够你判断返回的到底是什么。

判断登录成功的依据是响应里有没有data字段。抓包时正常返回的那串 JSON 里带着用户信息,字段名叫data;失败时通常返回status加错误描述。所以bool(payload.get('data'))比判断 HTTP 200 更可靠——HTTP 层成功不代表业务层成功。

3.3 gzip 与编码这两个隐形杀手

Accept-Encoding: gzip这一行是从抓包里原样搬过来的,但urllib2不会自动解压。服务端看到这个头,会返回压缩后的字节流,你read()出来的东西喂给json.loads直接报编码错误,打印出来是一堆\x1f\x8b开头的乱码。

两条路可选。一是把Accept-Encoding从 header 里删掉,服务端就返回明文,代价是流量大一些,抓几百条话题完全无所谓。二是保留这个头,拿到响应后自己解:

import gzip from StringIO import StringIO raw = opener.open(req).read() if raw[:2] == '\x1f\x8b': # gzip 魔数,靠前两个字节判断 raw = gzip.GzipFile(fileobj=StringIO(raw)).read()

我一般选第二条,因为删掉Accept-Encoding会让请求头和真实 APP 产生差异,某些接口会因此判定为非客户端。判断是否压缩不要看响应头里的Content-Encoding——有些服务端返回压缩体却不带这个头,靠魔数判断最稳。

编码问题是另一个。响应里可能有中文,json.loads在 Python 2.7 下对str和unicode的处理比较微妙,稳妥做法是先raw.decode('utf-8')再解析,或者解析后统一encode('utf-8')再输出。脚本头部那两行#!/usr/local/bin/python2.7和# -*- coding: utf8 -*-不是装饰,源文件里有中文字符串时没有第二行会直接报语法错误。

4. JSON 落地:话题列表解析与 timestamp 游标翻页

登录只是拿到入场券,真正的数据在话题接口里。这份资源的目标接口是/V2/Treehole/Message/getMessageByTopicIdV3.action,同样是 POST 表单,参数结构和登录接口高度相似,只是把账号口令换成了topicId、genderType、timestamp这几个业务字段。

4.1 先看结构再写解析

不要一上来就写for循环。先把一次响应用json.dumps(..., indent=2, ensure_ascii=False)打出来看层级,把字段路径记清楚,再动手。

响应字段路径含义备注
status业务状态码1表示成功,其他值需要看message
data.timestampLong翻页游标本轮最后一条的时间戳,下一次请求带上它
data.messageBOs话题数组列表,逐个元素解析
data.messageBOs[].content话题正文可能为空,需要判空
data.messageBOs[].schoolName学校名展示用
data.messageBOs[].messageId话题唯一 ID去重时用它,比正文可靠
data.messageBOs[].issueTime发布时间字符串格式
data.messageBOs[].studentBO.gender发帖人性别嵌套一层

字段路径看清楚了,解析代码就是照抄。防两件事:content可能缺字段,studentBO也可能缺。直接each['content']在字段缺失时抛KeyError,一条脏数据就能把整批采集打断。

4.2 防御式取值与字段裁剪

# -*- coding: utf-8 -*- def fetch_data(json_data): """从一页响应里抽出话题列表,返回 (翻页游标, 话题列表)""" data = json_data.get('data') or {} timestamp = data.get('timestampLong', 0) message_list = data.get('messageBOs') or [] topic_list = [] for each in message_list: # 正文为空的话题直接跳过,没有采集价值 if not each.get('content'): continue student = each.get('studentBO') or {} # 嵌套对象也可能是 None topic_list.append({ 'content': each.get('content', ''), 'schoolName': each.get('schoolName', ''), 'messageId': each.get('messageId', ''), 'gender': student.get('gender', ''), 'time': each.get('issueTime', ''), }) # 边解析边输出,长任务里能直观看到进度 print each.get('schoolName', ''), each.get('content', '')[:30] return timestamp, topic_list

几个参数值得说清楚。

json_data.get('data') or {}这种写法比json_data['data']安全,get返回None时后面的or {}兜底,避免AttributeError: 'NoneType' object has no attribute 'get'。同理studentBO那一层。

if not each.get('content')这行判断同时过滤了字段不存在和空字符串两种情况。原文代码里写的是if each.get('content', False),效果一样,我这里用not更符合直觉。

messageId一定要存下来。后面做去重、做增量更新、做断点续采,全靠这个字段。靠正文哈希去重会被「转发同一句话」的场景坑到。

content打印时截前 30 个字符,是因为终端里一条长话题能把屏幕刷满,看不到进度。真正入库时存完整的。

4.3 递归翻页改成 while 循环

原文的翻页是用函数自己调自己实现的:拿到时间戳,再请求一次,再拿时间戳,如此往复。能跑,但有两个毛病——递归深度受 Python 限制,理论上几千页会RuntimeError;而且没法在中途插入「最多抓 N 页」或者「遇到重复 ID 就停」这类控制逻辑。

# -*- coding: utf-8 -*- import json, urllib2 TOPIC_URL = 'http://203.0.113.10/V2/Treehole/Message/getMessageByTopicIdV3.action' MAX_PAGES = 50 # 硬性上限,防止跑飞 SEEN_IDS = set() # 去重集合,已经见过的 messageId def fetch_topic_page(opener, headers, timestamp, topic_id=19, select_type=3): body = ( 'timestamp=%s&phoneBrand=Meizu&platform=1&genderType=-1&' 'topicId=%s&phoneVersion=16&selectType=%s&channel=MXMarket&' 'phoneModel=M040&versionNumber=7.2.1&' ) % (timestamp, topic_id, select_type) headers['Content-Length'] = str(len(body)) # 每页 body 长度不同,必须重算 req = urllib2.Request(TOPIC_URL, body, headers) return opener.open(req).read() def crawl(opener, headers): timestamp, page, all_topics = 0, 0, [] while page < MAX_PAGES: raw = fetch_topic_page(opener, headers, timestamp) payload = json.loads(raw) if payload.get('status') != 1: print 'stop: status != 1, msg =', payload.get('message') break timestamp, topics = fetch_data(payload) fresh = [t for t in topics if t['messageId'] not in SEEN_IDS] if not fresh: print 'stop: no new items on page', page break for t in fresh: SEEN_IDS.add(t['messageId']) all_topics.extend(fresh) print 'page %d done, +%d, total %d' % (page, len(fresh), len(all_topics)) if timestamp == 0: break page += 1 return all_topics

Content-Length每页都要重算,这是很多人抄代码时漏掉的。原文里是headers['Content-Length'] = '159'写死的,只要时间戳位数变化(10 位和 13 位差 3 个字符),长度就不对了。

timestamp == 0这个判断是终止条件之一。当服务端返回的游标是 0,说明没有更早的数据了,继续请求只会拿回同样的内容,形成死循环。这类「服务端用 0 表示结束」的约定在这类接口里很常见。

fresh为空就停,是为了防止服务端游标不推进导致无限重复。这种玄学问题在真实接口上确实出现过——游标返回了同样的值,脚本就一直原地打转。加上这一层,最坏情况也只是提前停,不会把磁盘写满。

MAX_PAGES是后悔药,调试阶段设小一点(比如 3),确认逻辑没问题再放开。真实采集时按目标量级设,别设成无限。

5. 排查清单:五类翻车现场的现象、原因与解法

调试这类脚本,最耗时的不是写代码,是判断「现在卡在哪一层」。下面这几条都是我在复现同类接口时真金白银踩出来的,按现象、原因、解决三段写,对着排查基本能覆盖八成问题。

5.1 返回登录错误,但账号密码确定没写错

现象:脚本请求登录接口,返回的 JSON 里带错误描述,换成 curl 请求同一个接口同样失败,但用手机 APP 登录一切正常。

原因基本锁定在请求头。服务端识别客户端靠的是User-Agent、Host、Content-Type这几个字段的组合,缺一个或者值不对,就会被当成非官方客户端拒掉。原文作者提到「一开始没有加 header,得到的是登录错误」,正是这个。另一个可能是Content-Length与服务端读到的实际字节数不符,请求体被截断,服务端解析出的账号密文就是残缺的。

解决方法是逐字段比对。把脚本发出的完整请求(可以用req.headers打出来)和 Fiddler 里抓到的 Raw 请求一行一行对,重点看User-Agent里的Dalvik版本号、机型串、Host是否带端口。不要凭记忆写,一定抄。

5.2 响应拿到手是一堆乱码,json.loads 直接抛异常

现象:print raw[:100]输出以\x1f\x8b\x08开头,json.loads报ValueError: No JSON object could be decoded。

原因就是 gzip。请求头里带了Accept-Encoding: gzip,服务端按约定返回压缩体,而urllib2不像requests那样自动解压。

解决办法有两条,前面 3.3 已经写过:删掉Accept-Encoding,或者用gzip.GzipFile手动解。我建议留着头、手动解,判断依据看前两个字节是不是\x1f\x8b,不要依赖Content-Encoding响应头,有些服务端不返回这个头但照样压缩。

5.3 HTTP 200,但响应体是空的

现象:resp.getcode()返回 200,len(raw)却是 0,或者只有几个字节。

原因通常有两种。一是重定向被urllib2自动跟随了,最终落到一个空页面,原始响应被吞掉。二是接口对Connection头敏感,Keep-Alive在某些中间层上表现异常。还有一个隐蔽原因:表单体的编码格式不对,服务端按x-www-form-urlencoded解析却读到空串。

排查顺序是先看resp.geturl(),和请求 URL 不一致说明发生了重定向;再用resp.info().items()把响应头打全,看有没有Location、Content-Length: 0。如果确实是重定向,用urllib2.HTTPRedirectHandler关掉自动跟随,看第一跳返回什么。

5.4 翻页停不下来,或者抓了几页全是重复内容

现象:脚本一直跑,日志里load successful刷屏,但新数据量为零;或者游标始终是同一个值。

原因在终止条件。这类接口用timestampLong做游标,正常情况下每页都会推进;但遇到话题总量少于单页数量、或者服务端游标逻辑异常时,它会返回和上一次相同的值。原文的递归版本没有对这种情况做防护,会一直递归下去直到栈溢出。

解决办法是加两道闸:一是记录上一次的游标,如果本次和上次相同就break;二是用messageId集合判重,本页全是已见过的 ID 就停。我在 4.3 的代码里两处都加了,实际跑下来很稳。

5.5 抓包工具一开,APP 就提示网络异常

现象:手机代理配置正确、证书也装了,但打开 APP 就是转圈或者报「网络连接失败」,Fiddler 里一条会话都没有。

原因分两种。如果是 Fiddler 里完全没有记录,说明请求根本没走到代理,检查手机 Wi-Fi 代理是否生效、电脑防火墙是否拦了 8888 端口。如果有Tunnel to但看不到明文,说明证书没被信任,或者 APP 做了证书锁定(SSL Pinning),后者基本无解,需要换思路。

还有一个容易被忽略的点:部分 APP 在检测到代理时会主动断开,这时候连登录都进不去。判断方法是把代理关掉重试,能正常登录就是被识别了。这种情况不要硬顶,换个采集目标更实际。

6. 从能跑到能复用:参数外置、限速与结果校验

脚本能跑通只是起点。真正要用起来,还有三件事得做:参数别写死在代码里、请求别打太密、结果得能验证对不对。

参数外置解决的是「换一个话题就得改代码」的问题。把topicId、selectType、设备码、账号密文这些全部挪到一个配置文件或者命令行参数里,代码只负责流程。我用的是最土的办法——一个config.py,里面全是常量,改配置不动主逻辑:

参数来源变更频率
TOPIC_ID抓包时看到的业务字段换采集目标时改
DEVICE_CODE抓包表单里的设备码一般固定
ACCOUNT/PASSWORD抓包得到的密文换账号时改
MAX_PAGES自己定调试时设 3
SLEEP_SEC自己定建议不低于 2

限速这条我必须单独说。原文代码里翻页是不带任何等待的递归,一页接一页地打。本地测试没感觉,真跑起来几十页连着发,服务端很快会有反应——轻则返回错误码,重则直接封掉这个设备码。加个time.sleep(2)成本极低,效果立竿见影。这不是「优化」,是保命。

结果校验最容易被跳过。跑完几百条之后怎么知道数据是对的?我的做法是抽样比对:随机挑三条,去抓包记录里找对应的原始响应,把messageId、content、issueTime三个字段逐字对比。字段错位、编码错乱、时间戳精度丢失,这三类问题靠肉眼看日志是发现不了的,必须回到原始响应比对。另外统计一下「总条数 / 去重后条数」,比值明显小于 1 说明游标逻辑有问题,翻页翻重了。

从那以后我每次复现这类接口,都强制走一遍「curl 先通 → 单页解析 → 三页翻页 → 抽样比对」这四步,绝不跳步直接写完整脚本。跳过任何一个,后面都得用双倍时间找回来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询