简介:围绕Python爬虫抓取手机APP传输数据这一主题,这份PDF教程面向具备Python基础、希望了解移动端接口采集与JSON解析的技术爱好者和研发工程师,帮助读者建立从抓包分析到数据提取的完整认知。内容涵盖Fiddler抓包定位登录与话题接口、构造HTTP请求并补充请求头与Cookie、使用urllib2模拟客户端完成登录,以及对返回的JSON数据进行字段解析和下拉加载处理,案例以超级课程表话题信息为对象,既讲实现思路,也提示请求伪装、登录态维持和异常排查中的常见问题。包内共1个PDF文件,大小约143KB,篇幅紧凑,适合作为移动端数据采集的入门参考和代码思路索引。已有367人学习,读者可从中了解接口分析、请求构造、JSON字段提取及Python2.7环境下的实现方式,并认识平台反爬带来的调整需求。
1. 抓APP的数据,难点从来不在解析
先给一个反直觉的结论:写这类脚本,八成的调试时间花在“让服务端认我是客户端”,而不是花在json.loads上。手机 APP 的接口跟网页接口是两套东西,它不认浏览器,只认自己人——认那个Dalvik/1.6.0开头的 User-Agent,认那个精确到字节的Content-Length,认登录之后种下来的那颗 Cookie。这份 Python 爬虫源码包讲的正是这条链路:用 Fiddler 把手机端的 HTTP 请求截下来,把 URL、Header、表单三样东西原样搬进urllib2,先模拟登录拿到会话,再用timestamp游标一轮一轮拉话题列表,最后对返回的 JSON 做字段提取和落库。它面向的是已经会写 for 循环、但对“协议层面对齐客户端”没概念的人。下面按抓包还原、会话登录、JSON 解析、游标翻页、排错排查的顺序拆开讲,代码保留 Python2.7 原貌,同时给出 Python3 的等价写法。
2. 抓包还原:Fiddler 里那六个字段怎么抄进代码
2.1 APP 接口为什么反而比网页“好抓”
网页抓取的痛点是结构不稳定:DOM 一改版,昨天的 xpath 今天就全空;再加上 JS 渲染、class 名混淆、懒加载,解析层要写的兜底逻辑比请求层还多。APP 接口吐的是 JSON,字段名稳定,改版通常只加字段不删字段,解析层反而省心。
代价在于校验更死板。浏览器里你少带一个Referer,大概率还是 200;APP 接口少带一个Content-Type,服务端直接判非法请求,返回status: 0加一句“登录错误”。这就是很多人第一次跑这套代码时最懵的地方:地址没错、账号没错、参数没错,就是登不进去。血泪经验是——抓包抓到什么就原样抄什么,一个 header 都别自作主张删。
2.2 抓包之后必须记录的六项内容
Fiddler 的会话列表里点开一条请求,右侧的 Inspectors 面板分上下两半,上半是 Request,下半是 Response。要抄的不是整段 raw,而是下面这六项,缺一项后面就要返工:
| 抓包位置 | 具体内容 | 代码里的落点 | 漏了会怎样 |
|---|---|---|---|
| Request 首行 | 完整 URL 与 Method | loginUrl/topicUrl | 请求打到错误路径,404 或直接跳登录页 |
| Request Headers | Content-Type | headers字典 | 服务端不解析表单体,返回参数缺失 |
| Request Headers | User-Agent | headers['User-Agent'] | 被识别为非客户端,返回风控页 |
| Request Headers | Host | headers['Host'] | HTTP/1.1 下部分网关直接 400 |
| Request Headers | Content-Length | headers['Content-Length'] | 服务端读不满 body,参数被截断 |
| Request Body | 表单原文 | loginData字符串 | 少了设备信息或渠道号,登录校验不过 |
还有一项不在上表里,但同样关键:Response 的 JSON 骨架。登录成功返回什么、失败返回什么,字段名叫status还是code,值1表示成功还是0表示成功,必须在抓包阶段就看清楚,别等写完代码再猜。
2.3 把抓包结果翻译成第一个能跑的请求
原项目用的是 Python2.7 的urllib2,请求体的构造方式是把表单拼成一个key=value&key=value&的字符串,末尾那个&服务端一般能容忍。下面这段是登录请求的最小骨架,域名和参数值都换成了占位符,实际以你抓到的为准:
# -*- coding: utf-8 -*- import urllib2 from cookielib import CookieJar # 抓包得到的登录地址,域名与路径按实际替换 loginUrl = 'http://app-gw.example.com/V2/StudentSkip/loginCheckV4.action' headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', # 客户端 UA,抓包是什么就填什么,不要改成浏览器 UA 'User-Agent': 'Dalvik/1.6.0 (Linux; U; Android 4.1.1; M040 Build/JRO03H)', 'Host': 'app-gw.example.com', 'Connection': 'Keep-Alive', # 注意:带 gzip 就必须自己解压,否则拿到的是压缩字节流 'Accept-Encoding': 'gzip', 'Content-Length': '207', # 必须等于下面 loginData 的真实字节数 } # 表单体:账号密码在客户端已加密,直接原样回放即可 loginData = ('phoneBrand=Meizu&platform=1&deviceCode=000000000000000' '&account=0123456789ABCDEF0123456789ABCDEF' '&phoneVersion=16' '&password=FEDCBA9876543210FEDCBA9876543210' '&channel=MXMarket&phoneModel=M040&versionNumber=7.2.1&') cookieJar = CookieJar() opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookieJar)) req = urllib2.Request(loginUrl, loginData, headers) loginResult = opener.open(req).read() print(loginResult)这段代码的逻辑顺序值得逐段看清楚。CookieJar是一个容器,HTTPCookieProcessor是把它挂到opener上的处理器,两者组合之后,服务端Set-Cookie下发的会话标识会被自动收进 jar,后续用同一个opener发请求时自动带上。这也是为什么后面抓话题必须复用opener而不能重新build_opener——换一个 opener 等于换一个空罐子,会话就断了。
参数方面的几个关键点:deviceCode是设备标识,同一账号换设备码容易触发风控,建议固定一个值反复用;platform、channel、phoneVersion、versionNumber这四个是客户端环境声明,服务端可能拿它们做灰度分流,改动会导致返回的数据结构不一样;account和password在客户端加密过,不要试图在脚本里解密再加密,直接回放密文最稳,代价是密文通常和设备绑定,换机器要重新抓一次。
2.4 Content-Length 是第一个翻车点
Content-Length写的是请求体的字节数,不是字符数,也不是你数出来的参数个数。Python2 里loginData是str(字节串),len(loginData)直接就是字节数;Python3 里字符串是 Unicode,得写len(loginData.encode('utf-8')),否则中文参数一进来长度就错。
长度不匹配会发生什么?取决于服务端实现:严格一点的 Nginx 层直接返回 400;宽松一点的会按声明长度截断 body,你后面那几个参数就凭空消失了,日志里看到的是“缺少 channel 参数”,而你的代码里明明写了。所以正确的做法不是手动填一个数字,而是在构造完 body 之后动态算:
# Python3 写法:先编码,再取长度,避免中文/特殊字符导致长度偏差 body = 'phoneBrand=Meizu&platform=1&deviceCode=000000000000000' body_bytes = body.encode('utf-8') headers['Content-Length'] = str(len(body_bytes))我一般会在发请求前打印一次len(body_bytes)和 header 里的值做比对,多花两秒,能省掉半小时的“为什么参数丢了”。另外Accept-Encoding: gzip这项要留意:urllib2不会自动解压,服务端一旦真返回 gzip,你read()出来的就是二进制乱码,json.loads会直接抛异常。想省事就把这项删掉,让服务端返回明文;想保留压缩就得在读取后手动gzip.decompress。
3. 模拟登录:CookieJar 才是会话的真正载体
3.1 三个必带 header 各自在管什么
Content-Type里的application/x-www-form-urlencoded是在告诉服务端“按表单格式解析 body”,少了它,服务端可能把 body 当 JSON 或者干脆忽略,表现就是所有参数都读不到。User-Agent走的是客户端识别逻辑,很多网关会把非客户端 UA 的请求导向风控或者精简版接口。Host在 HTTP/1.1 里是必需项,尤其在目标是一台共享网关的时候,服务端靠它做虚拟主机路由。
原项目里有一句很实在的记录:一开始没加 header,拿到的是登录错误。这不是玄学,是服务端在 header 不完整时走了失败分支。
3.2 登录成功的判据不能靠 if 变量
原始代码里有一处必须改掉的地方:
# 反面写法:loginResult 只要不是空字节串就为真,几乎永远成立 if loginResult: print('login successful!')opener.open(req).read()返回的是字节串,哪怕服务端返回的是{"status":0,"msg":"password error"},这个判断照样成立,脚本会打印“登录成功”,然后在抓话题那一步拿到空列表,你会误以为是翻页逻辑写错了。正确的判据是解析返回体,找业务字段:
# Python3 + requests 的登录段,判据落在业务状态码和会话标识上 import requests session = requests.Session() session.headers.update({ 'User-Agent': 'Dalvik/1.6.0 (Linux; U; Android 4.1.1; M040 Build/JRO03H)', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Accept-Encoding': 'identity', # 关掉压缩,省去手动解压 }) resp = session.post(login_url, data=login_body, timeout=10) payload = resp.json() # 双判据:业务 status 为 1,且 data 里带回了用户标识 if payload.get('status') == 1 and payload.get('data', {}).get('userId'): print('login ok, cookie count =', len(session.cookies)) else: # 把原始返回打出来,排错时这行比任何日志都值钱 print('login failed:', resp.text[:300])requests.Session承担的角色和CookieJar加HTTPCookieProcessor完全一样:自动收 Cookie、自动回传。区别是它把连接池也一起管了,连续翻几十页时不会反复建连,速度会明显不一样。如果你手上还是 Python2.7 的老脚本,不必急着重构,只要保证登录和抓取用的是同一个 opener 就行。
3.3 迁移到 Python3 时的三个等价替换
老代码里有几处 Python2 专属写法,迁移时按下面这张表替换,逻辑不变:
| Python2 写法 | Python3 等价写法 | 说明 |
|---|---|---|
import urllib2 | import urllib.request | 模块合并,Request、urlopen都在这里 |
from cookielib import CookieJar | from http.cookiejar import CookieJar | 包名变更 |
opener.open(req).read() | 返回 bytes,需.decode('utf-8')再json.loads | 否则json.loads报类型错误 |
第三项最容易漏。Python2 里json.loads对str和unicode都宽容,Python3 里传 bytes 进去会直接抛TypeError,报错信息还挺隐晦。我一般的习惯是封装一个read_json(resp)的小函数,在里面统一做解码,调用方永远拿 dict。
3.4 会话过期是长跑的常态
登录拿到的 Cookie 有有效期,跑几十页没问题,跑几千页就可能中途失效,表现为突然返回status: 0或者{"msg":"请先登录"}。稳妥的做法不是加大超时,而是在翻页循环里检查每次返回的业务码,一旦命中未登录特征,就重新走一遍登录流程再继续,同时把当前游标保存下来,避免从头再来。
def ensure_login(session, login_url, login_body, retry=2): """检查会话是否仍然有效,失效则重新登录""" for _ in range(retry): resp = session.post(login_url, data=login_body, timeout=10) if resp.json().get('status') == 1: return True return False把这段放在翻页循环的异常分支里调用,比在主流程里定时重登更精准,也不会白白浪费请求次数。
4. JSON 解析与游标翻页:timestamp 是怎么驱动无限加载的
4.1 先看清骨架,再动手写解析
抓包时把话题接口的响应体格式化一下,结构大概是这个样子(字段名按原项目保留,值做了脱敏):
{ "status": 1, "data": { "timestampLong": 1700000000000, "messageBOs": [ { "messageId": "m_1001", "content": "今天图书馆人真多", "schoolName": "某高校", "issueTime": "2023-11-15 09:12:33", "studentBO": { "gender": 1 } } ] } }三层结构要分清:最外层status是业务状态码;data是载荷容器;data里面同时塞了下一页的游标(timestampLong)和本页的列表(messageBOs)。这个设计很常见,好处是一次请求就能拿到“数据 + 继续要数据的凭据”,坏处是解析函数必须同时返回两个东西,不能只返回列表。
4.2 fetch_data 的字段提取与容错
原项目的fetch_data做得基本对,唯一欠缺是对缺失字段的容错。studentBO在某些匿名发言的场景下可能不存在,直接each['studentBO']['gender']会抛KeyError,整个循环就断了。改成链式.get之后,坏一条记录不影响其余几百条:
def fetch_data(json_data): """从一页响应里提取游标和话题列表,字段缺失时降级为空值""" data = json_data.get('data') or {} next_cursor = data.get('timestampLong') # 下一页的游标 messages = data.get('messageBOs') or [] topic_list = [] for each in messages: # content 为空的话题直接跳过,多半是纯图片或已删除 content = each.get('content') if not content: continue student = each.get('studentBO') or {} topic_list.append({ 'messageId': each.get('messageId'), 'content': content, 'schoolName': each.get('schoolName', ''), 'gender': student.get('gender', -1), 'time': each.get('issueTime', ''), }) return next_cursor, topic_listmessageId是后面去重的主键,务必保留;issueTime用来做结果校验,正常情况下同一页内的时间应该是递减的(服务端按时间倒序返回),如果出现乱序,说明selectType这类排序参数传错了。
4.3 游标翻页:从哪来、往哪塞
首次请求的timestamp传0,服务端返回最新一页,同时带回一个timestampLong。第二次请求把这个值原样塞进表单的timestamp字段,就能拿到更早的一页。如此往复,直到返回的列表为空或者游标不再变化。
原始代码用的是递归:load里调load。这在页数少的时候没问题,页数一多就两个隐患——Python 默认递归深度约 1000,翻到几百页就可能RecursionError;更要命的是递归没有终止保护,万一服务端在末页仍然返回同一个游标,脚本会原地打转直到崩掉。改成while加三重保护:
import time def crawl_topic(opener, topic_url, headers, max_pages=200, interval=1.0): """循环翻页,带页数上限、游标不变检测和请求间隔""" cursor = 0 seen_cursors = set() all_topics = [] for page in range(1, max_pages + 1): body = ('timestamp=%s&phoneBrand=Meizu&platform=1&genderType=-1' '&topicId=19&phoneVersion=16&selectType=3' '&channel=MXMarket&phoneModel=M040&versionNumber=7.2.1&') % cursor headers['Content-Length'] = str(len(body.encode('utf-8'))) req = urllib2.Request(topic_url, body, headers) raw = opener.open(req, timeout=10).read() payload = json.loads(raw.decode('utf-8')) if payload.get('status') != 1: print('page %d failed: %s' % (page, raw[:200])) break next_cursor, topics = fetch_data(payload) if not topics: print('empty page, stop at page %d' % page) break # 游标不变说明服务端没给下一页,防止死循环 if next_cursor in seen_cursors: print('cursor repeated, stop at page %d' % page) break seen_cursors.add(next_cursor) all_topics.extend(topics) print('page %d ok, %d topics, next cursor %s' % (page, len(topics), next_cursor)) cursor = next_cursor time.sleep(interval) # 给服务端留口气,也降低被限流的概率 return all_topics三重保护各管一件事:max_pages兜住失控,seen_cursors兜住游标原地踏步,interval兜住请求过密。这三个如果不加,脚本第一次跑通很容易,第二天再跑就可能被限流或者跑到内存爆掉。
4.4 参数里那几个容易被忽略的字段
翻页表单里除了timestamp,还有topicId、selectType、genderType三个值需要盯住。topicId是板块编号,换个板块就是另一批内容;selectType决定排序或筛选方式,原项目里出现过328、366、3三个不同取值,返回值确实不一样,抓包是什么就填什么,别自己猜;genderType=-1表示不限性别,改成0或1会过滤掉一部分内容。这三个参数一旦和抓包不一致,最典型的表现是“能翻页但内容重复率很高”,因为服务端按另一套排序规则返回,游标推进的逻辑就对不上了。
5. 排查:抓包能通、脚本跑不通的五种情况
5.1 登录返回错误码,但 Fiddler 重放同一条请求是成功的
现象是脚本打印“登录失败”,把 URL 和 body 手动复制到抓包工具里重放却正常。原因基本落在两处:一是Content-Length和实际 body 字节数对不上,服务端截断了 body,后面的参数没读到;二是 body 字符串末尾的&拼接多了一个或少了一个字段,比如把deviceCode写成了devicecode,服务端大小写敏感。
解决方式是发请求前把body和len(body.encode('utf-8'))一起打印出来,和抓包面板里的长度逐字节比对。我吃过一次亏:某个字段值里带了个空格,肉眼看不出来,长度差了 1,调了四十分钟。
5.2 能登录,但话题列表永远返回空数组
现象是登录判据通过了,翻页接口也返回status: 1,但messageBOs是空列表。常见原因是topicId或selectType传错,比如把板块号当成了话题号;另一个高频原因是首次请求的timestamp传了字符串'None'而不是0,服务端按非法游标处理,返回空页但不报错。
解决方式是把首次请求的游标硬编码成0,并且把每一页的完整响应体打印一次,看data下面还有没有类似hasMore的字段——有的话直接按它判断终止,比猜游标可靠得多。
5.3 请求能返回,但 json.loads 报编码或类型错误
现象是read()之后json.loads抛TypeError或UnicodeDecodeError。两个原因:Python3 下read()返回 bytes,必须先decode('utf-8');或者 header 里带了Accept-Encoding: gzip,服务端真的压缩了,你拿到的是 gzip 字节流,前两个字节是\x1f\x8b。
解决方式二选一:把Accept-Encoding改成identity让服务端返回明文,或者在读取后判断魔数再gzip.decompress。我一般选前者,少一层依赖。
5.4 跑了几十页之后突然开始返回未登录
现象是前面都正常,某个页开始status变成 0,或者返回体里出现“请先登录”字样。原因是会话 Cookie 到期,或者服务端检测到同一账号短时间高频请求,主动踢掉了会话。
解决方式是在翻页循环里识别未登录特征码,命中后调用重新登录函数刷新 Cookie,然后从当前游标继续,不要从头开始。同时把interval从 1 秒调到 2 到 3 秒,长跑场景下这个改动比任何重试都有效。
5.5 第二次运行拿到大量重复数据
现象是第一次跑完整,第二次跑出来的结果里有一半和上次重合。原因是没有做去重,timestamp=0永远从最新一页开始,两次运行的时间窗口必然重叠。
解决方式是用messageId做唯一键,落库前先查一次,或者在内存里维护一个seen_ids集合。如果数据量上了十万级,就把去重放到写入层用主键冲突来处理,别在内存里堆集合。
6. 让脚本能跑第二次:去重、限速与结果校验
第一次跑通和能长期跑,中间隔着的不是功能,是稳定性。下面这三个技巧是我每次写采集脚本都会加的,加完之后的返工率明显下降。
先说落库和去重。最省事的方案是 SQLite,单文件、无部署、支持主键冲突忽略,用INSERT OR IGNORE一行就能把去重做掉:
import sqlite3 conn = sqlite3.connect('topics.db') conn.execute('''CREATE TABLE IF NOT EXISTS topic ( message_id TEXT PRIMARY KEY, -- 用接口返回的 messageId 做主键 content TEXT, school TEXT, gender INTEGER, issue_time TEXT, fetched_at TEXT DEFAULT CURRENT_TIMESTAMP )''') def save(topics): """批量写入,重复 messageId 自动忽略""" rows = [(t['messageId'], t['content'], t['schoolName'], t['gender'], t['time']) for t in topics] conn.executemany( 'INSERT OR IGNORE INTO topic ' '(message_id, content, school, gender, issue_time) ' 'VALUES (?, ?, ?, ?, ?)', rows) conn.commit()messageId做主键这一条很关键。有些帖子内容为空、有些用户改过昵称,拿内容或昵称做唯一键迟早出问题,只有服务端下发的 ID 是稳定的。
再说限速。固定sleep(1)是入门做法,更稳的是带随机抖动的退避:正常页间隔 1 到 2 秒随机,连续两次失败就把间隔翻倍,成功后再降回来。这样既不会因为太规律被识别,也不会在服务端抖动时疯狂重试。
最后是结果校验,这一步很多人省掉,然后在数据用了一周之后才发现字段是空的。我习惯在每次运行结束时统计一组指标:
| 校验项 | 期望表现 | 不通过时的排查方向 |
|---|---|---|
| 总条数 | 与上一轮相比波动在合理区间 | 游标参数或topicId变了 |
| 空内容占比 | 低于 5% | 字段名改版,content换了键名 |
| 时间单调性 | 同页内issueTime递减 | selectType排序参数传错 |
| 主键重复率 | 接近 0 | 去重逻辑没生效或主键选错 |
这四个指标跑一次只要几行 SQL,但能提前发现绝大多数“跑得通但数据不对”的问题。有一次我发现空内容占比突然从 2% 涨到 60%,追下去是服务端把content挪到了contentV2下面,接口照样返回status: 1,不校验根本看不出来。
从那以后,我每次改完采集脚本,都会先只跑三页,把原始响应、解析结果、入库条数三样东西各打一遍,确认对得上再放开跑全量。这个习惯救过我很多次——尤其是那种接口没报错、但字段悄悄换名字的改版。希望帮到你。
本文还有配套的精品资源,点击获取