简介:这份 PDF 文档面向具备 Python 基础、希望了解移动端数据采集流程的开发者,围绕手机 APP 传输数据的抓取与解析展开。内容以超级课程表为例,串联 Fiddler 抓包定位接口、构造 HTTP 请求、补充必要 Header、携带登录表单完成模拟登录、使用 urllib2 保持会话以及解析 JSON 返回数据等环节,较完整地呈现了从数据捕获到提取话题信息的实现思路。资源包共 1 个 PDF 文件,大小约 143KB,文件精简,便于按案例查阅关键代码、请求参数与调试要点;示例运行环境为 Python2.7,并提醒读者关注平台反爬机制、服务条款与合规使用边界。目前已有 367 人学习,适合想入门 APP 接口分析、模拟请求与结构化数据提取的爬虫爱好者,也可为定制化信息采集方案提供参考。
1. 登录接口一直返回“账号或密码错误”:八成问题不在密码上
账号或密码错误,是 Python爬虫 做 APP数据抓取 时最先遇到的障碍。抓包工具里 Fiddler 明明显示了 200 响应,返回里也有完整的用户 JSON,可同样的地址、同样的表单搬到 Python 里跑,服务端却明确告诉你账号密码不对。很多人第一反应是去解密那个加密的 password 字段,试图还原加密算法,结果花了一整晚也没成功——真正的原因往往不在加密,而在 HTTP请求 本身:少的可能是一个 User-Agent,多的可能是一行手写的 Content-Length,错的可能只是表单尾部那个没被注意到的 &。这类故障看着像鉴权问题,本质是请求构造问题和协议细节问题,适合已经会用 urllib2 发请求、也能看懂 JSON解析 结果,但一遇到“抓包能成、代码不成”就卡住的开发者。
2. 抓包能跑通、代码跑不通:把请求的四项要素逐项对齐
这类采集任务的排查顺序是固定的:先确认“客户端到底发了什么”,再确认“我发了什么”,然后逐字节比对。抓包工具负责第一步,Python 代码负责第二步,中间的差异就是根因。下面三处是按出错频率从高到低排列的,我建议按这个顺序依次验证,不要跳着看。
2.1 表单编码与请求行:先把 Method、URL、body 三样对齐
很多人拿到抓包结果后,第一件事是把 form 里的字段“整理”一遍,删掉觉得没用的字段、调整一下顺序、顺手把尾部多余的&去掉。这三个动作里任何一个都可能让请求失效。服务端校验签名或者拼接待校验字符串时,字段顺序和前导、尾随的&都是参与计算的,少一个字符,签名就不匹配,返回的自然就是鉴权失败。
正确的做法是:抓包抓到什么就原样复制什么,只替换真正需要变化的业务值。
# -*- coding: utf-8 -*- import urllib2 from cookielib import CookieJar LOGIN_URL = "http://<app-host>/V2/StudentSkip/loginCheckV4.action" # 表单原样照抄抓包结果:字段顺序、尾部的 & 都不要动 # 只把 account / password / deviceCode 三个业务值替换成你自己的 form_body = ( "phoneBrand=Meizu&platform=1&deviceCode=<device-code>" "&account=<encrypted-account>&phoneVersion=16" "&password=<encrypted-password>&channel=MXMarket" "&phoneModel=M040&versionNumber=7.2.1&" ) 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": "<app-host>", "Connection": "Keep-Alive", "Accept-Encoding": "gzip", # 注意:这里故意不写 Content-Length,交给 urllib2 自己算 } cookie_jar = CookieJar() opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookie_jar)) req = urllib2.Request(LOGIN_URL, form_body, headers) resp = opener.open(req) raw = resp.read() print resp.headers.get("Content-Type") print len(raw), raw[:200]这段代码里有几个容易被忽略的点,值得说清楚。form_body是字符串而不是字典,就是为了保证字段顺序,一旦用urllib.urlencode(dict)转一次,顺序取决于字典的哈希顺序,Python 2.7 里这个顺序和你抓包看到的几乎不可能一致。headers里不写Content-Length,因为 urllib2 在发出请求前会自己算一个,你写的值如果和实际 body 长度不一致,服务端按你写的长度去读 body,就会截断或者读到空,表现同样是“账号密码错误”。resp.read()之后先打印长度和前 200 个字节,是为了避免后面直接json.loads报一个毫无信息量的异常——这一步是判断“服务端到底返回了 JSON 还是别的什么”的唯一可靠办法。
如果服务端确实对表单做了签名校验,那还有一个更隐蔽的问题:加密后的密文通常带时效或者绑设备。抓包拿到的那串password密文,过一段时间再用就会失效,返回仍然是“账号或密码错误”。判断方法很简单:把抓包工具重新打开,同一账号重新登录一次,看密文有没有变化。变了,说明它和某个时间戳或随机数绑定,就不能长期复用,只能每次登录前重新获取,或者在代码里自己实现同样的加密逻辑。
2.2 Header 少一项 User-Agent,服务端就把你当外人
这是我最常遇到的坑,原文作者也踩过:一开始没加 header,返回的就是登录错误。原因很直白,移动端接口通常会根据User-Agent判断请求来源,看到Python-urllib/2.7这种默认值,网关层或者业务层会直接走到“非法客户端”的分支,返回一个和密码错误长得一模一样的提示,让你以为是鉴权问题。
需要带的头并不多,但每一项都有明确用途:
| Header | 作用 | 漏掉后的典型表现 |
|---|---|---|
User-Agent | 标识客户端类型和版本 | 返回“账号或密码错误”“客户端版本过低” |
Content-Type | 声明 body 是表单编码 | 服务端取不到参数,全部为空 |
Host | 部分网关按 Host 路由到不同集群 | 404 或返回通用错误页 |
Accept-Encoding | 声明可接受压缩响应 | 见 3.2,返回体变成乱码 |
Connection | 控制长连接复用 | 高频请求时偶发连接重置 |
User-Agent建议直接从抓包里整行复制,包括里面的Dalvik/1.6.0 (Linux; U; Android 4.1.1; ...)这段。不要自作聪明改成新的 Android 版本号,服务端可能按这个字段匹配白名单,版本对不上一样会被拒。
写 Header 时还有一处细节:Python 2 的urllib2会把 header 名的首字母大写,如果你在抓包里看到的是user-agent这种全小写,也不用改,服务端不会计较大小写。真正会出问题的是值——值里的空格、分号、括号必须一个不差。
验证 Header 是否带对的办法,不是靠猜,而是把服务端返回的原始字节打出来看。原来的代码里只print loginResult,遇到压缩响应或者二进制内容,终端上是一堆乱码,你什么都看不出来。改成输出状态码、Content-Type和长度三样,故障范围立刻缩小一半。
2.3 手写 Content-Length,是在给自己提前制造问题
原文代码里有一行非常典型:
headers['Content-Length'] = '159'这是照着抓包结果抄下来的值。抓包那一刻的 body 确实长 159 字节,可你的 body 一旦多一个字段、换一个更长的设备号、时间戳从 10 位变成别的位数,长度就变了。此时你发出去的请求头说“body 有 159 字节”,实际发出去的可能是 163 字节,服务端读满 159 就认为请求体结束,剩下 4 个字节要么被丢掉,要么被当成下一个请求的开头,最终呈现出来的现象就是参数缺失、登录失败,甚至响应直接乱掉。
正确做法是永远不手写这个头:
# 错误示范:抓包值是 159,换了设备号就崩 # headers['Content-Length'] = '159' # 正确做法:让 urllib2 根据 body 实际长度自己算 req = urllib2.Request(LOGIN_URL, form_body, headers) print len(form_body) # 需要对比时,打印真实长度,而不是写死urllib2.Request在构造时如果第二个参数不为None,内部会调用add_data,自动算出Content-Length并加到 header 里;即使你手动塞了一个值,它也会用自己算的覆盖掉。所以“手写 Content-Length”这件事在 urllib2 里往往是无效操作——但如果你换用httplib或者自己拼 socket,那它就是真实存在的隐患。原文里那段headers['Content-Length'] = '159'之所以“看起来能跑”,正是因为 urllib2 帮忙兜住了;一旦有人把它改写成底层连接或者搬到别的库上,问题立刻显现。这种“看起来生效、实际被覆盖”的代码最危险,因为它会让人误以为这个字段必须手写,然后在别的项目里照抄,最后出问题。
顺带说一个同源问题:Host头。抓包里带Host是因为 HTTP/1.1 强制要求,用 urllib2 按完整 URL 发请求时会自动生成,手工再写一遍基本无害,但如果 URL 里带了端口而Host里没带,某些网关会路由失败。稳妥的方式是让库自动生成,除非你明确知道要改写目标。
3. 登录成功但拿不到话题:Cookie 和压缩响应这两处最容易失败
登录通过只是前半程。真正的数据在第二个、第三个接口上,而这两个接口依赖登录态。这里失败的表现通常是:返回一个空数组、返回“未登录”、或者干脆抛一个 JSON 解析异常。下面三件事按顺序检查。
3.1 Cookie 没跟着走:CookieJar 和 opener 必须成对出现
模拟登录网站和模拟登录 APP 接口,在这一步上没有区别:登录响应里的Set-Cookie必须被保存下来,并在后续请求里带上。urllib2 本身不保存 Cookie,必须靠CookieJar配合HTTPCookieProcessor,而且关键在于——后续请求要用同一个opener发出去。
# -*- coding: utf-8 -*- import urllib2 from cookielib import CookieJar cookie_jar = CookieJar() # 这个 opener 持有 cookie_jar 的引用,登录和后续请求必须都用它 opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookie_jar)) # 第一次:登录,响应中的 Set-Cookie 会被写进 cookie_jar opener.open(urllib2.Request(LOGIN_URL, form_body, headers)).read() # 核对一下登录态到底存下来没有 for c in cookie_jar: print c.name, c.value, c.domain, c.path # 第二次:带登录态取数据,仍然用同一个 opener req2 = urllib2.Request(TOPIC_URL, topic_body, headers) payload = opener.open(req2).read()常见的错误写法是:登录用opener,取数据时图省事写urllib2.urlopen(req2)。urlopen用的是全局默认 opener,和你的cookie_jar一点关系都没有,服务端收到的就是一个没有 Cookie 的裸请求。现象很有迷惑性——返回status是 0 或者返回空列表,看起来像是“数据接口变了”,实际上只是你没带登录态。
另一个容易忽略的点是domain和path。打印出来的 Cookie 如果domain是.example.com而你的请求打到了裸 IP 或者别的子域,浏览器和CookieJar都不会自动带上它。这种情况要么把请求发到同一个域名,要么手动把 Cookie 塞进 header。
Python 3 里用requests.Session()就没有这个坑,Session 对象天然持有 Cookie,只要后续请求走s.get/s.post就行。这也是我后来迁移老脚本时优先改掉的部分——不是为了新语法,而是为了少一个出错点。
3.2 Accept-Encoding 开了 gzip 却不解压,json.loads 直接报错
原文的 header 里带了'Accept-Encoding': 'gzip',这在移动端接口里非常常见,因为响应体动辄几十上百 KB,压缩后能省一大半流量。但 urllib2 不会自动解压。你拿到的read()结果是 gzip 压缩后的二进制,直接json.loads就会抛出类似ValueError: No JSON object could be decoded的错误,而且打印出来的内容是乱码,看着特别像“被加密了”。
# -*- coding: utf-8 -*- import gzip import StringIO import json def read_body(resp): """按 Content-Encoding 决定是否解压,返回解压后的 str""" data = resp.read() encoding = resp.headers.get("Content-Encoding", "") if encoding.lower() == "gzip": # gzip 头两字节固定是 \x1f\x8b,可以作为二次确认 data = gzip.GzipFile(fileobj=StringIO.StringIO(data)).read() return data raw = read_body(opener.open(urllib2.Request(TOPIC_URL, topic_body, headers))) print raw[:300] # 先看是不是可读的 JSON payload = json.loads(raw)resp.headers.get("Content-Encoding")是判断依据,不能靠猜;有些服务端只在响应体超过一定大小时才压缩,所以同一个接口在不同topicId下可能一次压缩一次不压缩,用固定逻辑解压反而会在未压缩的那次报错。StringIO.StringIO(data)是把 bytes 包成文件对象,因为GzipFile需要一个 file-like 对象,这是 Python 2 的写法,Python 3 里换成io.BytesIO并把read()结果当 bytes 处理即可。
还有一个更省事的思路:如果你不需要极致省流量,把Accept-Encoding这个头直接去掉,服务端就不压缩,后面的解压逻辑全都可以不要。移动端接口对缺失这个头通常不敏感,因为它只是一个“客户端能力声明”。我在写一次性脚本时基本都这么干,少一层处理就少一个出错的地方。
3.3 登录判定写错,后面全白做
原文里有一段判断:
loginStatus = json.loads(loginResult).get('data', False) if loginResult: print 'login successful!'loginStatus算出来了却没用上,真正决定分支的是if loginResult:。只要请求没抛异常,loginResult就是一个非空字符串,哪怕内容是{"status":0,"msg":"密码错误"},也会打印“login successful”。这种“判定条件写错但不报错”的情况最难查,因为它把真实错误吞掉了,后面的失败全部延迟到取数据阶段才暴露,让你以为是第二个接口的问题。
正确的判定方式是看业务状态字段,并且把失败时的原始响应打出来:
# -*- coding: utf-8 -*- import json def check_login(raw): """返回 (是否成功, 解析后的 dict),失败时打印原因""" try: data = json.loads(raw) except ValueError: print "响应不是 JSON,前 200 字节:", raw[:200] return False, {} # 不同接口的状态字段名不一样,status / code / ret 都见过,按抓包结果选 if data.get("status") != 1: print "登录失败,服务端返回:", data.get("msg") or data.get("message") return False, data return True, data关键在两点:一是用业务字段判断,不要用“有没有返回内容”判断;二是失败分支必须把服务端的话打印出来。服务端返回的msg往往直接写明了原因,比如“设备号不合法”“客户端版本过低”,这比任何猜测都快。
4. 下拉加载停不下来:timestamp 游标和递归翻页的典型报错
话题列表这类接口都是分页的,靠一个游标往下翻。原文用的是timestamp,每次响应里回传下一个游标,下一次请求原样带回去。这个设计本身没问题,但实现方式稍微不注意,就会变成死循环、栈溢出或者中途断掉。
4.1 游标不前进就是死循环,必须显式比较
把游标传下去、再把新游标取回来,看起来是个闭环,实际上有两个地方会断:一是服务端在某些情况下返回的游标和上次一样(比如最后一页之后返回空列表但游标不变),二是你自己忘了把新游标赋回去。前者会静默地无限请求,后者会一直拉第一页。两种表现都是“程序在跑,但数据不增加”,日志刷得飞快,很容易被当成正常。
# -*- coding: utf-8 -*- import json import time import urllib2 def fetch_page(opener, url, headers, timestamp, topic_id=19): body = ( "timestamp=%s&phoneBrand=Meizu&platform=1&genderType=-1" "&topicId=%s&phoneVersion=16&selectType=3" "&channel=MXMarket&phoneModel=M040&versionNumber=7.2.1&" % (timestamp, topic_id) ) req = urllib2.Request(url, body, headers) return json.loads(read_body(opener.open(req))) def fetch_all(opener, url, headers, max_pages=100): timestamp = "0" # 首次请求的游标,抓包看到的是什么就填什么 seen = set() # 记录走过的游标,防止来回跳 for page in range(max_pages): payload = fetch_page(opener, url, headers, timestamp) if payload.get("status") != 1: print "第 %d 页失败:" % page, payload.get("msg") break data = payload.get("data") or {} items = data.get("messageBOs") or [] next_ts = data.get("timestampLong") if not items: # 没有数据了,正常结束 print "第 %d 页为空,结束" % page break if next_ts in seen: # 游标回环,立刻止损 print "游标重复:%s,结束" % next_ts break seen.add(next_ts) timestamp = next_ts time.sleep(0.5) # 给服务端留点喘息,别把限流招来max_pages是兜底,防止任何没想到的分支导致无限跑;seen集合用来捕捉游标回环;time.sleep(0.5)是经验值,移动端接口对短时间高频请求比较敏感,连续几十次不带间隔,很可能在某一次开始返回status != 1,而你还在傻等。timestamp=0是起始游标,一定要以抓包为准,有的接口用0,有的用空串。
4.2 用递归翻页,栈会先撑不住
原文的load函数在末尾调用自己,靠递归实现翻页。这在数据量小的时候能跑,一旦话题多、翻几十上百页,就会撞上 Python 的递归深度限制,抛出RuntimeError: maximum recursion depth exceeded。更麻烦的是,递归版本里每一层的headers是同一个字典对象,前面改Content-Length、后面又改,跨层互相污染,出问题极难定位。
# 错误示范:递归翻页,深度不可控,且共享 headers 字典 # def load(timestamp, headers, url): # headers['Content-Length'] = '159' # ... # load(next_ts, headers, url) # 正确做法:循环替代递归,每次构造独立的 headers 副本 def build_headers(base, body_len=None): h = dict(base) # 复制,避免污染调用方的字典 if body_len is not None: h["Content-Length"] = str(body_len) return h改成循环之后,翻多少页都不会爆栈,而且退出条件写在一处,比递归里层层return False清楚得多。dict(base)这个复制动作看着多余,实际上是把“共享可变对象”这个隐患彻底掐掉——跨请求共享的字典是 Python 脚本里最常见的隐性 bug 来源之一,尤其是多线程或者多任务并发跑的时候。
4.3 字段缺失与中文编码:解析阶段的两个隐藏问题
取数据的函数里,原文是这么写的:
topicDict['gender'] = each['studentBO']['gender']这里直接用下标,一旦某条话题的studentBO是null(匿名发言、账号已注销都会这样),就会抛TypeError: 'NoneType' object has no attribute '__getitem__',整个翻页循环当场中止。更稳妥的方式是全程用.get加默认值:
# -*- coding: utf-8 -*- def parse_topics(payload): data = payload.get("data") or {} next_ts = data.get("timestampLong") topic_list = [] for each in data.get("messageBOs") or []: content = each.get("content") if not content: # 空内容直接跳过,不写脏数据 continue student = each.get("studentBO") or {} # 可能为 None,兜底成空字典 topic_list.append({ "content": content, "schoolName": each.get("schoolName", ""), "messageId": each.get("messageId"), "gender": student.get("gender"), "time": each.get("issueTime"), }) return next_ts, topic_listdata.get("messageBOs") or []这个写法同时处理了“字段不存在”和“字段存在但值为 null”两种情况,比单纯.get(k, [])更稳,因为后者在值为None时不会回退到默认值。
中文编码是另一个高频问题。Python 2 里文件头只写# -*- coding: utf8 -*-,源文件里的中文是 UTF-8 编码,但print到 GBK 终端时可能抛UnicodeEncodeError。稳妥的写法是在输出前显式编码,或者统一用json.dumps(..., ensure_ascii=False)落盘再检查。注释里那行#!/usr/local/bin/python2.7也值得保留——很多机器上python默认是 2.7,但换一台就可能是 3.x,脚本头部写死解释器路径能省掉一堆“在我这儿能跑”的沟通成本。不过更彻底的做法是尽早迁到 Python 3,urllib2、cookielib、StringIO这几个模块在 3 里全都改了名,早晚要动一次。
5. 排查清单:五个让我重跑整晚的细节
下面五条都是实际踩过、并且每一条都单独导致过“跑一晚上全是废数据”的记录。按“现象 → 原因 → 解决”写,遇到同类表现可以直接对号入座。
5.1 请求与登录段的三个坑
坑一:返回“账号或密码错误”,但抓包工具里同样的请求是成功的。原因几乎总是请求头和抓包不一致,最常见的是漏了User-Agent,其次是Content-Type写成了application/json。服务端按 Content-Type 决定用哪种方式解析 body,类型不对时所有参数都是空,校验自然失败。 解决:把抓包里的请求头整段复制过来,逐项对比。验证方式是把真实发出的 header 打印出来,urllib2.Request的header_items()能看到最终值,别靠记忆。
坑二:登录接口返回 200,但响应体是一堆看不懂的乱码。原因是请求头带了Accept-Encoding: gzip,服务端压缩了响应,而代码没解压就当成字符串处理。表现上很像是“接口加密了”,容易往错误方向查。 解决:用resp.headers.get("Content-Encoding")判断后再解压;或者干脆去掉这个请求头。判断依据永远是响应头,不是肉眼猜。
坑三:登录成功,但紧接着的数据接口返回未登录。原因是第二次请求换了opener,或者把登录时的cookie_jar弄丢了。也可能是 Cookie 的domain和实际请求的域名对不上,CookieJar拒绝发送。 解决:全程用同一个opener;登录后遍历cookie_jar打印name / domain / path,确认登录态真的存进去了,并且域名匹配。
5.2 解析与调度段的两个坑
坑四:翻页翻到某一页突然中断,日志停在一条看不出问题的记录上。原因是解析函数里用了each['studentBO']['gender']这类链式下标,遇到null字段直接抛异常,而外层没有try,整个循环崩掉。这类异常往往发生在几十页之后,重跑一次未必复现,特别浪费时间。 解决:解析路径上全部改用.get并给默认值,对可能为null的嵌套对象先or {}兜底;外层循环里对单条记录加try/except,一条坏数据不应该终止整批任务。
坑五:脚本跑得飞快,但数据量始终不增长。原因是游标没有真正前进:要么next_ts取回来没赋给timestamp,要么服务端在末页返回相同游标导致无限循环。 解决:循环里加三个护栏——最大页数、游标去重集合、items为空即退出。这三个护栏加起来不到十行代码,但能省掉整晚的白跑。
注意:所有护栏都只是止损,不能替代对返回内容的检查。每次请求后把
status、msg、数据条数打到日志里,出问题时才有据可查。
6. 快速定位:从一个报错到根因的固定排查顺序
我现在处理这类采集脚本,基本不看报错文本本身,而是先判断它落在哪一层。分层之后,绝大部分问题五分钟内能定位。
6.1 先定层,再看具体报错
| 现象 | 所在层 | 第一件事 |
|---|---|---|
| 返回“账号或密码错误”“未登录” | 请求构造层 | 打印实际发出的 header 和 body 长度,和抓包逐项对比 |
ValueError: No JSON object could be decoded | 响应处理层 | 打印响应头Content-Encoding和前 200 字节原始内容 |
KeyError/TypeError: NoneType | 解析层 | 定位到具体字段,改用.get加默认值 |
| 脚本在跑但数据不增长 | 调度层 | 打印每页游标和条数,确认游标在前进 |
maximum recursion depth exceeded | 调度层 | 递归改循环 |
这张表的价值在于:报错文本本身经常骗人。No JSON object could be decoded看着像数据格式变了,实际上九成是 gzip 没解压;KeyError看着像接口改了字段,实际上九成是某条记录字段为null。先分层,能避免在错误的方向上改代码。
6.2 我的固定三步验证
第一步永远是“打印原始响应”。不看解析后的对象,只看status码、Content-Type、Content-Encoding和响应体前 200 字节。这一步能排除掉压缩、重定向、错误页三种情况,而它们占了失败原因的一大半。
第二步是“比对三个值”:body 的实际长度、header 里的Content-Length、抓包里的长度。三者不一致,问题就在请求构造上,不用往下查。
第三步是“单页跑通再放量”。先用timestamp=0只请求一页,确认能解析出至少一条完整记录,再打开翻页循环。直接跑全量再回头找错,日志里全是噪声,定位成本翻好几倍。
还有一个习惯值得养成:给每个请求打一行结构化日志,包含页码、游标、耗时、状态字段、数据条数。这套日志平时看着冗余,出问题时它就是黑匣子——尤其是那种“跑了两小时才断”的任务,没有日志只能从头再来。
提示:脚本第一次跑通时,把当时的完整响应体各存一份到本地文件(登录响应、第一页、中间页、末页)。后面接口行为一变,拿这份基线做 diff,比任何猜测都快。
说个血泪教训:我曾经因为一个手写的Content-Length,在同一段代码上反复改了三个小时,先后怀疑过加密算法、设备指纹、账号风控,最后发现只是 body 多了一个空格。从那以后我每次接新的采集任务,都强制走一遍“打印原始响应 → 比对三个值 → 单页跑通”这三步,再动手写解析逻辑。这套流程看着笨,但它把玄学变成了可验证的步骤。希望帮到你。
本文还有配套的精品资源,点击获取