简介:面向具备Python基础、希望入门移动端数据采集的开发者,这份PDF以超级课程表APP为例,完整演示从抓包到解析的实践路径。内容围绕用Fiddler抓取手机APP数据包、定位登录地址与表单参数展开,并借助urllib2构造带Header的HTTP请求、配合CookieJar模拟客户端登录,再对返回的JSON数据逐层提取话题内容、学校、发布时间等字段,并给出下拉翻页递归加载的实现思路。压缩包约143KB,仅含1个PDF文件,篇幅紧凑,代码与说明对应。已有368人学习下载,适合作为移动端爬虫练手的参考。读者可从中获得抓包定位接口的方法、请求头与表单参数的构造要点、JSON字段解析与翻页加载的具体写法,以及反爬受阻时的排查方向;文档同时提示了合规使用边界与Python2.7环境限制。
1. 从一条被拦下的请求说起
模拟登录一个校园话题社区,代码写完跑第一次就返回“登录错误”,把抓包工具里的原始报文和代码里的请求逐字比对,才发现少了一个Content-Type和一个User-Agent——服务端靠Dalvik/1.6.0这个标识判断来访者是手机客户端,缺了它,账号密码再对也是白搭。这就是 Python 爬虫抓取手机 APP 传输数据最容易翻车的地方:难点不在算法,而在于把手机端那条 HTTP 请求一个字节不差地复刻出来。整套方案围绕三件事展开——用 Fiddler 把 APP 的请求报文还原成可复现的接口文档,用 urllib2 加 cookielib 维持登录态,用 json 模块把分页返回的话题数据一层层剥下来落库。技术栈是 Python 2.7,适合要交毕设或课设的同学照着搭,也适合需要给某个垂直社区做定制采集的开发者直接改。
2. 抓包与协议还原:把手机端的一次点击翻译成可复现的 HTTP 报文
写爬虫之前先得知道要抓什么。手机 APP 和网页最大的区别是它没有给你看源码的机会,所有接口都藏在 APK 的网络层里,唯一的入口就是抓包。这一步做扎实了,后面写代码基本就是填空题;这一步糊弄过去,后面每一行代码都在猜。我一般会把“录制”和“还原”分成两件事:录制是把一次完整操作产生的请求序列原样存下来,还原是从这串请求里挑出真正承载数据的那些,把它们的 URL、Header、Body 三个要素固定成文档。
2.1 抓包环境的搭建:让手机的流量落到抓包工具里
Fiddler 装在一台和手机同一个 Wi-Fi 的机器上,默认监听 8888 端口。手机上把当前 Wi-Fi 的高级选项里那一栏填成抓包机的内网地址加端口,流量就会先经过抓包工具再出去。HTTPS 的请求默认是看不到明文的,需要在 Fiddler 的 Tools > Options > HTTPS 里勾选解密选项,然后手机浏览器访问http://<抓包机IP>:8888下载并安装根证书。
这里有个安卓 7 之后才出现的坑:应用默认只信任系统证书区,装在用户证书区的根证书对目标 APP 不生效,抓出来一片 CONNECT 隧道,看不到任何明文。常见的做法是把证书装进系统证书区,或者在测试机上跑一个信任全部证书的调试版本。这一步没有捷径,证书没装对,后面所有工作都是空中楼阁。
环境通了之后,按一个固定的操作序列走一遍:冷启动 APP、输入账号密码点登录、进入话题页、下拉两次加载更多。这四步产生的请求序列就是一个最小可复现样本,后面代码里每一个参数都能在这串记录里找到出处。我习惯在 Fiddler 里给这几条关键请求打上颜色标记,导出成.saz存档,出事的时候有后悔药可吃。
2.2 请求三要素:URL、Header、表单体各管什么
一条 POST 请求拆开就是三块。URL 决定打哪个接口,Header 决定服务端认不认你是合法客户端,表单体决定这次请求要什么数据。
以登录接口为例,路径形如/V2/StudentSkip/loginCheckV4.action,表单体是一串key=value&拼接的字符串:
phoneBrand=Meizu&platform=1&deviceCode=<设备码>&account=<加密账号> &phoneVersion=16&password=<加密密码>&channel=MXMarket &phoneModel=M040&versionNumber=7.2.1&account和password都是定长的十六进制串,是被客户端加密过的;deviceCode是设备唯一码,channel是应用市场渠道,versionNumber是客户端版本号。这几个字段看着像垃圾参数,实际上服务端大概率会做校验——版本号对不上会要求升级,渠道对不上可能走不同的风控策略。所以最稳的做法是原样照抄抓包结果,不要自作聪明去精简。
Header 里最关键的是两个。Content-Type: application/x-www-form-urlencoded; charset=UTF-8告诉服务端按表单格式解析请求体,缺了它服务端可能把整个 body 当成空。User-Agent是安卓 Dalvik 虚拟机的标识串,服务端用它区分手机客户端和浏览器,这也是开头那次登录失败的直接原因。
提示:
Content-Length不要手写。urllib2 构造 Request 时会根据 body 实际长度自动补上,手写一个固定值(比如 207)在 body 长度变化时必然对不上,服务端读到截断的数据包,返回的就是一个莫名其妙的错误码。
2.3 把抓包结果整理成接口文档
抓完包别急着写代码,先花二十分钟把关键接口整理成一张表。这张表是后面所有模块的契约,也是答辩时最能体现工作量的一页材料。
| 接口 | 方法 | 路径 | 关键 Header | 请求体核心字段 | 响应关键字段 |
|---|---|---|---|---|---|
| 登录 | POST | /V2/StudentSkip/loginCheckV4.action | Content-Type、User-Agent、Host | account、password、deviceCode、versionNumber | data(账号信息) |
| 话题列表 | POST | /V2/Treehole/Message/getMessageByTopicIdV3.action | 同上 | timestamp、topicId、selectType、genderType | data.timestampLong、data.messageBOs |
| 加载更多 | POST | 同上 | 同上 | timestamp(上一页返回值) | 同上 |
表的最后一列最值钱。timestampLong是服务端给的分页游标,messageBOs是这一页的话题数组,理解了这两个字段,第 4 章的分页逻辑基本就通了。整理文档时顺手把每条请求的响应样本各存一份 JSON 到本地,后面调试解析代码时不用反复重新抓包。
3. 会话与登录:把一次性请求变成一条能持续下去的会话
抓包抓到的是“一次”请求,爬虫要的是“一直”请求。这两者的差别在状态——登录成功后服务端会下发 Cookie,后续每一个接口都靠这个 Cookie 认人。urllib2 本身是无状态的,得靠 cookielib 里的 CookieJar 挂在一个 opener 上,才能让登录和抓取共享同一份会话。
3.1 加密参数的定位:不逆算法,先复用密文
account和password是加密后的串,硬啃加密算法对课设级别的项目来说性价比太低。可行的策略有两条。
第一条是直接复用:同一个账号密码在同一个设备指纹下,密文往往是固定的(没有时间戳参与),抓一次就能一直用。这条路的代价是换账号要重新抓,且密文可能有时效。
第二条是找规律:把同一个密码在不同时间点抓两次,对比密文是否变化。变了说明掺了时间戳或随机盐,只能去逆算法;没变说明是纯哈希,直接抄。这个判断十分钟能做完,却能省下几天的逆向工作。
我一般的做法是把这些参数抽成配置,而不是硬编码在代码里:
# config.py Python 2.7 API_HOST = 'http://api.example.com' # 抓包得到的接口域名,替换为实际值 LOGIN_URL = API_HOST + '/V2/StudentSkip/loginCheckV4.action' TOPIC_URL = API_HOST + '/V2/Treehole/Message/getMessageByTopicIdV3.action' 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': 'api.example.com', 'Connection': 'Keep-Alive', } # 设备指纹类字段,原样照抄抓包结果 DEVICE = ('phoneBrand=Meizu&platform=1&deviceCode=<设备码>' '&phoneVersion=16&channel=MXMarket' '&phoneModel=M040&versionNumber=7.2.1&') # 敏感字段单独放,方便替换 CREDENTIAL = 'account=<加密账号>&password=<加密密码>&'把设备指纹和账号凭证分开配置,好处是换账号时只动一行。DEVICE里那串字段看着啰嗦,但它们是服务端做设备校验的依据,少一个都可能触发风控。
3.2 CookieJar 与 Opener:一次构建,全程复用
登录和后续抓取必须共用同一个 opener 实例,这是整个方案里最容易写错的一行。
# login.py Python 2.7 import urllib2 from cookielib import CookieJar import json from config import LOGIN_URL, HEADERS, DEVICE, CREDENTIAL # CookieJar 负责在内存里保存服务端下发的 Cookie cookie_jar = CookieJar() # 把 Cookie 处理器挂到 opener 上,之后所有请求自动带 Cookie opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookie_jar)) def login(): body = DEVICE + CREDENTIAL req = urllib2.Request(LOGIN_URL, body, HEADERS) # Content-Length 自动补 raw = opener.open(req).read() try: obj = json.loads(raw) except ValueError: return False, raw # 以业务状态码为准,而不是以响应非空为准 if obj.get('status') == 1 or obj.get('data'): return True, obj return False, obj三个参数值得说明。CookieJar是内存态的容器,进程退出即丢,适合单次任务;要跨进程复用就换成LWPCookieJar存成文件。HTTPCookieProcessor是处理器,把它挂到 opener 上之后,opener.open()发请求前会自动从 jar 里取 Cookie 塞进 Header,收到Set-Cookie又自动写回 jar。urllib2.Request(url, body, headers)的第二个参数一给,请求方法自动变成 POST,第三个参数就是前面那张 Header 表。
用这个 opener 打登录接口,返回的 JSON 里带账号信息和Set-Cookie,说明登录成功,会话建立完毕。
3.3 登录成功的判定标准:别拿响应非空当成功
抓包原文里有一处值得单独拎出来说:判断登录成功的写法是if loginResult:,只要响应非空就算成功。这个判定在实际运行中几乎必然误报——服务端返回“密码错误”也是一个合法的 JSON 响应,非空,于是程序一路绿旗飘过,跑到抓取接口才报错,排查时根本不知道问题出在哪。
正确的判定要看业务状态码。大多数 APP 接口的响应形如:
{"status": 1, "data": {"userId": "...", "token": "..."}}status == 1表示业务成功,status == 0或负数表示失败,msg字段里带原因。有些接口不用status而用code或直接看data是否存在,具体看抓包结果——但无论哪种,判定依据都应该是那个明确的业务字段,而不是响应长度。
我习惯在登录函数里加一层日志,把失败时的原始响应完整打出来:
def login_or_die(): ok, obj = login() if not ok: # 失败时打印原始响应,这是唯一的黑匣子 print 'login failed, raw response: %r' % (obj,) raise SystemExit(1) print 'login ok, cookie count = %d' % len(cookie_jar) return objlen(cookie_jar)这一行看着多余,其实是很好用的探针:登录成功但 Cookie 数为 0,说明会话根本没建立,后面所有请求都会被判未登录。这种情况多半是 Cookie 域名匹配不上,或者服务端把 Cookie 放在了响应头的一个非标准字段里。
4. JSON 解析与分页递进:从一页数据到一条可续传的抓取链路
话题列表接口返回的是标准 JSON,结构不算深,但分页机制值得认真设计。服务端返回的游标是timestampLong,客户端下一次请求把它原样带回去,就能拿到更早的数据——这个模式在移动端接口里非常典型,几乎所有“下拉加载更多”都是它。
4.1 响应结构拆解与字段映射
先把一页响应的骨架拆开:
{ "status": 1, "data": { "timestampLong": 1490000000000, "messageBOs": [ { "messageId": 10001, "content": "...", "schoolName": "...", "issueTime": "2017-03-20 10:00:00", "studentBO": {"gender": 1} } ] } }data是业务载荷,timestampLong是翻页游标,messageBOs是这一页的条目数组。条目里的字段并不总是齐全,有些条目没有content(可能是纯图片帖),有些没有studentBO。这一点必须在解析时兜住,否则第一条残缺数据就能让整个程序崩掉。
# parser.py Python 2.7 def parse_page(json_obj): """把一页响应拆成 (游标, 行列表)""" data = json_obj.get('data') or {} cursor = data.get('timestampLong', 0) items = data.get('messageBOs') or [] rows = [] for each in items: # 没有正文的条目跳过,避免下游出现空记录 if not each.get('content'): continue student = each.get('studentBO') or {} rows.append({ 'message_id': each.get('messageId'), 'school': each.get('schoolName'), 'gender': student.get('gender'), 'issue_time': each.get('issueTime'), 'content': each.get('content'), }) return cursor, rows全部用.get()而不是下标访问,是这类解析函数的基本纪律。下标访问在图省事的时候很爽,等某天服务端给某条数据少返回一个字段,程序就在半夜崩了。timestampLong缺失时兜底成 0,等于“没有更多了”,比抛异常温和。
4.2 分页递进:把递归改成迭代
抓包原文里的加载更多是递归写的:拿到新游标就再调一次load()。递归在这里有两个问题——深度不可控(数据量大时直接爆栈),以及没有终止条件(服务端如果一直返回同一批数据,循环永远不会停)。工程上更稳的写法是迭代加三重终止:
# crawler.py Python 2.7 import urllib2, json, time from parser import parse_page from config import TOPIC_URL, HEADERS, DEVICE LOAD_TPL = ('timestamp=%s&genderType=-1&topicId=19' '&selectType=3&') + DEVICE def crawl_topic(opener, max_pages=50, interval=1.0): cursor = 0 all_rows = [] seen = set() # 用 messageId 去重,防止游标不前进导致死循环 for page in range(1, max_pages + 1): body = LOAD_TPL % cursor req = urllib2.Request(TOPIC_URL, body, HEADERS) raw = opener.open(req).read() obj = json.loads(raw) if obj.get('status') != 1: print 'page %d status error: %r' % (page, obj) break new_cursor, rows = parse_page(obj) fresh = [r for r in rows if r['message_id'] not in seen] if not fresh: print 'page %d no new data, stop' % page break seen.update(r['message_id'] for r in fresh) all_rows.extend(fresh) print 'page %d, +%d rows, cursor=%s' % (page, len(fresh), new_cursor) # 游标没有前进说明已经到底,直接退出 if new_cursor == cursor: break cursor = new_cursor time.sleep(interval) # 节流,别把服务端当压测对象 return all_rows参数逐个说明。max_pages是硬上限,防止任何形式的失控,课设里设成 50 页足够体现“无限加载”的效果。interval是请求间隔,一秒是经验值,太密会触发频控,太慢演示时不好看。seen集合用message_id去重,同时兼任循环终止的哨兵——如果服务端返回的游标没变、数据也没变,这一页全是旧数据,fresh为空,循环自然退出。三重终止(状态码异常、无新数据、游标未前进)叠加起来,比递归的裸奔写法稳得多。
4.3 结构化落库:让抓来的数据能二次使用
打印到控制台只能算演示,真正交付的方案得把数据落下来。字段和解析出来的字典一一对应,建表语句很直白:
CREATE TABLE topic_item ( message_id BIGINT NOT NULL PRIMARY KEY, school_name VARCHAR(64) DEFAULT NULL, gender TINYINT DEFAULT NULL, issue_time DATETIME DEFAULT NULL, content TEXT, fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP );主键用message_id而不是自增 ID,是为了让重复抓取天然幂等——第二次跑的时候INSERT ... ON DUPLICATE KEY UPDATE直接覆盖,不会产生脏数据。fetched_at记录入库时间,排查“某天的数据为什么少了”时全靠它。
写入时批量提交,别一条一条 insert:
def save(conn, rows, batch=100): sql = ('INSERT INTO topic_item ' '(message_id, school_name, gender, issue_time, content) ' 'VALUES (%s, %s, %s, %s, %s) ' 'ON DUPLICATE KEY UPDATE content=VALUES(content)') cur = conn.cursor() for i in range(0, len(rows), batch): chunk = rows[i:i + batch] cur.executemany(sql, [(r['message_id'], r['school'], r['gender'], r['issue_time'], r['content']) for r in chunk]) conn.commit()issue_time是字符串,入库前要确认数据库的时区和格式能对上,这一步翻车的概率不低,先在测试库跑一遍再上正式表。
如果想摆脱 Python 2.7 的历史包袱,用 requests 改写会更清爽,会话保持、gzip 解压、Content-Length 全是自动的:
import requests s = requests.Session() s.headers.update(HEADERS) s.post(LOGIN_URL, data=DEVICE + CREDENTIAL) # Cookie 自动存进 session r = s.post(TOPIC_URL, data=LOAD_TPL % 0) print r.json()['data']['messageBOs'][0]['content']同一套接口文档,两种技术栈都能实现,答辩时把这段对照摆出来,能说明你理解的是协议本身而不是某个库的用法。
5. 避坑与排查:五个最容易在答辩现场被问倒的地方
这套流程在实验室里跑通和在真实环境里跑通,中间隔着好几个坑。下面五条是我踩过之后印象最深的,按“现象 → 原因 → 解决”整理。
5.1 请求构造阶段的两个坑
现象一:登录接口永远返回“用户名或密码错误”,但抓包工具里同样的请求是成功的。
原因几乎一定是 Header 不全。服务端在鉴权之前先做客户端合法性校验,User-Agent缺失会被判为非法客户端,直接返回一个和密码错误长得一模一样的响应。另一个隐藏原因是Content-Length写成了固定值,body 长度一变就对不上,服务端读到半截数据。
解决方式是让库自己算长度,urllib2.Request(url, body, headers)不传Content-Length就行;Header 照抄抓包结果,Content-Type、User-Agent、Host三个一个都不能少。排查时最快的办法是把代码里构造的完整 body 和 Header 打印出来,跟抓包记录逐字 diff——十有八九是某个空格或换行。
现象二:返回的数据是乱码,json.loads直接抛异常。
原因在Accept-Encoding: gzip这个头。urllib2 不会自动解压 gzip 响应,你拿到的是一段二进制流,直接丢给 json 模块当然报错。
解决方式有两个:从 Header 里删掉Accept-Encoding,让服务端返回明文;或者保留这个头,读取后手动gzip.GzipFile解一层。课设场景下删掉更省事,但要注意有些服务端在不接受压缩时会返回不同的响应格式,删完得重新验证一遍字段。
5.2 会话与分页阶段的三个坑
现象三:登录成功,但抓取接口一直提示未登录。
原因是登录和抓取用了两个不同的 opener。build_opener每次调用都会产生一个新实例,Cookie 存在各自的 jar 里,互相看不见。
解决方式是全局只构建一个 opener,登录后把同一个对象传给抓取函数。判断是否真的共享,打印len(cookie_jar)就行,登录后应该是非零。如果确实是 0,检查响应头里Set-Cookie的域名和请求的Host是否匹配,域名对不上时 CookieJar 会直接丢弃。
现象四:第一页数据正常,第二页开始返回空数组或者直接报错。
原因通常是游标没用对。第一次请求timestamp=0,第二次必须把上一页返回的timestampLong原样带回,任何类型转换(比如转成 int 再拼字符串导致精度丢失)都可能让服务端认不出这个游标。
解决方式是把游标当字符串处理,不做数值运算;同时在循环里加一条“游标未前进就退出”的判断,避免服务端不认游标时程序空转。这个判断在 4.2 的代码里已经有了,别省。
现象五:程序跑着跑着内存暴涨,或者直接栈溢出。
原因是递归写法加上没有终止条件。抓包原文里的load()是尾递归,数据量一大就爆栈;服务端如果反复返回同一批数据,递归永远停不下来。
解决方式是改迭代、加页码上限、加去重集合,三条一起上。改完之后还要跑一次“服务端返回重复数据”的模拟测试——手动把某一页的响应存成 fixture 反复喂给解析函数,看循环能不能自己停下来。这个测试五分钟能写完,能省掉一次深夜排查。
注意:抓取频率要有节制。
time.sleep不是可选项,请求间隔设成 1 秒,既不影响演示效果,也不至于给服务端添麻烦。
6. 验收与答辩:用一页清单证明这条链路是对的
方案做完,最后一步是证明它真的能用。答辩现场最忌讳的是“我跑一遍给你看”,因为现场网络、服务端状态、账号有效性任何一个变量出问题都会翻车。我一般会准备一份验收清单,把“能不能跑”拆成五个可以独立验证的指标,每一项都有明确的判定方法和兜底证据。
| 验收项 | 判定方法 | 达标标准 | 证据 |
|---|---|---|---|
| 接口还原度 | 代码构造的请求与抓包记录逐字比对 | URL、Header、Body 三要素一致 | 对比截图或打印日志 |
| 登录成功率 | 连续执行登录 10 次 | 成功 10 次,Cookie 数非零 | 运行日志 |
| 分页连续性 | 抓取 10 页,检查游标序列 | 游标严格递减且无重复页 | 控制台输出 |
| 数据完整率 | 抽样 20 条,比对原始 JSON | 字段缺失率低于 5% | 抽样表格 |
| 幂等性 | 同一批数据跑两遍 | 库中记录数不变 | 表行数前后对比 |
验收时不依赖现场网络,提前把三类东西存成离线样本:一条登录成功的响应、一条话题首页响应、一条空数据响应。写一个--offline开关,让程序从本地文件读响应而不是发请求,演示时先在离线模式跑一遍,证明解析、分页、落库三个环节都对,再切到在线模式跑一小段。这个习惯救过我很多次——现场 Wi-Fi 抽风、账号被临时限制、服务端返回格式微调,任何一个都能让纯在线的演示当场卡住,而离线样本永远稳定。
答辩时最容易被追问的是“你怎么知道抓到的数据是完整的”。这个问题不要用“我看着像”回答,用游标序列回答:把每一页请求的timestamp和返回的timestampLong打印成一张表,游标一路递减到 0 或者不再变化,就说明这一条时间线走完了。再补一句去重集合的大小和入库行数的关系,完整性就有了可验证的依据。
还有一个加分项:把“失败路径”也演示一遍。手动改掉一个 Header 字段,让登录失败,展示程序打印的原始响应和退出码;手动让游标不前进,展示循环自己停下来。能讲清楚失败时会发生什么,比只讲成功路径更让人信服。
从那以后我每次做这类抓取项目,都会在写第一行业务代码之前先把抓包结果整理成接口文档、存好离线样本、写好验收清单,三样东西齐了才动手。这三步加起来不到两小时,却能把后面几天的返工挡在门外。希望帮到你。
本文还有配套的精品资源,点击获取