☰
Python移动端APP接口采集:抓包、会话保持与游标翻页实战
2026/11/2 2:20:04 网站建设 项目流程

简介:这份PDF文档围绕Python爬虫抓取手机APP传输数据展开,面向具备Python基础、了解HTTP请求的技术爱好者和研发工程师,帮助其理解从抓包分析到接口请求、再到JSON数据解析的完整链路。文档以超级课程表为例,涉及Fiddler抓包、urllib2构造请求、Header与登录表单处理、Cookie会话维持及话题数据提取等知识点,并基于Python 2.7环境给出经实测的示例代码,适合用于学习移动端接口采集与定制化信息采集系统的设计思路。压缩包内共1个PDF文件,约143KB,轻量便携,便于本地阅读与对照实践。该文档已有368人学习,内容聚焦抓包定位、请求模拟和JSON字段解析等关键环节,可帮助读者建立移动APP数据采集的基本分析框架,并了解反爬与合规边界等实际问题。

1. 从 APK 到 JSON:为什么手机 APP 的数据口子比网页更规整

做 Python 爬虫的人第一次去抓手机 APP 的传输数据,多半会先入为主觉得比网页难啃——没有 DOM,没有浏览器开发者工具,流量还可能是加密的。真拆下去结论往往反过来:网页那边要给浏览器渲染,你得从一堆标签里刨数据;APP 直接跟服务端换 JSON,字段名清清楚楚摆在那儿,省掉整个解析 HTML 的环节。难点从“解析”整体前移到了“还原请求”这一步:Header 里那串 Dalvik 开头的 User-Agent、表单中两个看不出明文的密文字段、翻页用的时间戳游标,这三样凑齐,剩下就是标准的数据抓取活。这份资料以某校园课程表类 APP 的话题列表为样本,走通了抓包、复刻登录、保住会话、按 timestamp 无限下拉、JSON 解析落库的完整链路,适合有 Python 基础、想搞清楚移动端接口采集到底卡在哪一环的研发同学,也适合手上有个类似需求、想找份能照着改的骨架的人。

2. 抓包定位接口:把一次登录拆成 URL、Header、Body 三段

移动端采集的第一道坎不是写代码,是“看不见”。浏览器有 F12,APP 没有,所以得先给自己造一个能看见请求的环境。这一章讲怎么让手机的流量落到电脑上、怎么从一条原始报文里把三样东西抄下来,以及碰到加密字段时该走哪条路。

2.1 让手机的流量落到本机监听端口

前置条件很简单:电脑和手机连同一个 Wi-Fi。抓包工具里找到监听设置,勾上允许远程设备连接,端口保持 8888 或者自己改一个没被占用的。然后在手机 Wi-Fi 的高级设置里,把网络手动配置打开,主机名填电脑的内网地址,端口填 8888,保存后重连。

电脑内网地址别靠猜,用命令查:

# 查本机内网地址,按自己的系统选一条 ipconfig getifaddr en0 # macOS 无线网卡 hostname -I | awk '{print $1}' # Linux ipconfig # Windows,看“IPv4 地址”那一行

命令执行完拿到形如192.168.x.x的地址,手机端填的就是它。这一步最容易出的问题是填完没网——多半是电脑防火墙把监听端口挡了,或者电脑和手机其实不在一个网段(一个连 5G 频段一个连 2.4G 也常见,某些路由器会隔离)。

配好之后打开 APP 操作一次,抓包列表里会瞬间刷出一大堆CONNECT开头的隧道请求,这基本就说明目标接口走的是 HTTPS,报文是加密的,你现在只能看到域名和端口。这时候要在手机浏览器里访问http://<电脑地址>:8888,把工具提供的那张根证书下载下来,装到系统里,并且一定要装进“受信任的凭据”那一栏,不能只装成普通用户证书。装完回 APP 再操作一次,如果列表里能看到明文的路径和参数,说明解密通了;如果还是一堆 CONNECT,检查证书是不是装错了位置,或者目标 APP 有没有做证书校验(这一类就不在普通抓包能解决的范围内了,得换思路,比如直接分析客户端的请求构造逻辑)。

抓包列表会非常吵,一个页面能刷几十条。用过滤功能只留目标域名,后面的分析会轻松很多。

2.2 从一条原始报文里抄下三样东西

筛出登录接口后,别急着看各种美化视图,直接切到 Raw,你要的是原始字节。一条典型的登录请求长这样:

POST /V2/StudentSkip/loginCheckV4.action HTTP/1.1 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.demo-edu.net Connection: Keep-Alive Accept-Encoding: gzip Content-Length: 207 phoneBrand=Meizu&platform=1&deviceCode=000000000000000&account=ACCOUNT_CIPHER&phoneVersion=16&password=PASSWORD_CIPHER&channel=MXMarket&phoneModel=M040&versionNumber=7.2.1&

报文结构就四段:请求行(方法 + 路径 + 协议版本)、Header 若干行、一个空行、Body。空行是硬分割线,没有它服务端不知道怎么区分头和体。对应到 Python 里,请求行里的路径拼上 Host 就是完整 URL,Header 那几行原样搬进字典,Body 那一整串搬进请求体参数。

把这次抓包的关键字段列出来,照着填就行:

抓包位置对应内容是否每次都变备注
请求行/V2/StudentSkip/loginCheckV4.action否版本号变了路径可能跟着变
User-AgentDalvik/1.6.0 ...否缺了大概率被判定为非客户端流量
Hostapi.demo-edu.net否与 URL 域名保持一致
Content-Length207是等于 Body 的实际字节数
Body: deviceCode设备标识是换设备就变,建议照抄
Body: account32 位十六进制否账号密文
Body: password32 位十六进制否密码密文
Body: versionNumber7.2.1否跟着客户端版本走

有两条我一般会强调:Content-Length别手抄,让程序自己算;Accept-Encoding这一行留着的话,服务端会真的返回 gzip 压缩过的内容,而 Python 2 的 urllib2 不会自动解压,json.loads会当场给你一个解码错误。这个坑后面还会细说。

2.3 加密字段:别一上来就想着逆算法

看到account和password都是 32 位十六进制,第一反应通常是 MD5。但采集场景里,真正省事的做法是先别逆。

我的习惯是分两条路判断。抓两次同样的账号密码,对比密文:两次完全一致,说明没有随机盐或者时间戳参与,这个密文就是个稳定常量,直接把抓到的值写死在脚本里,只要不改密码就能一直用;两次不一样,说明有 nonce 或者时间戳混进去了,这时候才需要去分析客户端的拼接逻辑,而且得先定位到那段代码才有戏。

对于只需要一个账号跑采集的场景,第一条路就够了,成本几乎为零。要批量跑几十上百个账号,才值得投入去逆。判断成本收益的时候,顺手确认一下客户端有没有把明文密码先做一次固定变换再做第二次哈希——这种情况即便逆出来,脚本里也得维护一套两段式加密,反而不如老老实实模拟客户端。

设备指纹那几个字段(phoneBrand、phoneModel、phoneVersion、channel、deviceCode)尽量从抓包里一字不改地照抄。有些服务端会拿这一组做风控,你今天改成phoneBrand=Other,明天可能就收到一个莫名的“参数错误”。手机型号、系统版本这类值不属于敏感信息,直接从抓包复制即可,别自己编。

3. urllib2 加 CookieJar:把登录请求一字不差地复刻出来

抓包拿到图纸,接下来是把它变成能跑的代码。这一章的三个点——会话怎么接住、请求体怎么拼、成功怎么判——任何一个做错,症状都是“登录失败”,但原因完全不同,得拆开看。

3.1 会话是怎么被 CookieJar 接住的

很多人第一次写这类脚本,会用urllib2.urlopen直接发请求,登录返回 200,然后拿着同样的代码去请求话题接口,得到一句“未登录”。原因是urlopen每次都是无状态的:服务端在登录响应里塞的Set-Cookie,被丢掉了,下一次请求自然没有身份。

正确的做法是先造一个带记忆的请求工厂:

# -*- coding: utf-8 -*- # Python 2.7:建立一个会记住 Cookie 的 opener import urllib2 from cookielib import CookieJar cookie_jar = CookieJar() opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookie_jar))

这三行的关系值得说清楚。CookieJar是个容器,负责存;HTTPCookieProcessor是个处理器,负责在发请求时从容器里取、收响应时往容器里写;build_opener把处理器装进一个 opener 里,产出一个带状态的请求工厂。关键点是:登录和后续所有请求必须共用同一个 opener 实例。你在登录时新建一个 opener,拉数据时又新建一个,等于换了个浏览器,Cookie 全丢。

还有一个隐蔽点:有些 APP 的鉴权不走 Cookie,登录成功后把 token 放在响应 JSON 里,后续请求要手动塞进 Header。判断方法很简单,看登录响应体里有没有类似token、sessionId的字段;同时看一眼后续请求的 Header 里是不是多了一项你没见过的键。两个都占的话,就按 Header 的方式处理。

3.2 表单体的拼接和 Content-Length 的真实算法

下面这段是把抓包还原成登录请求的完整写法,Python 2.7 环境:

# -*- coding: utf-8 -*- import json import urllib2 from cookielib import CookieJar LOGIN_URL = 'http://api.demo-edu.net/V2/StudentSkip/loginCheckV4.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.demo-edu.net', 'Connection': 'Keep-Alive', } # 参数顺序尽量和抓包保持一致,结尾那个 & 不要删 LOGIN_BODY = ( 'phoneBrand=Meizu&platform=1&deviceCode=000000000000000' '&account=ACCOUNT_CIPHER&phoneVersion=16&password=PASSWORD_CIPHER' '&channel=MXMarket&phoneModel=M040&versionNumber=7.2.1&' ) def login(opener): headers = dict(HEADERS) # 关键:按 UTF-8 字节数算,不是字符数 headers['Content-Length'] = str(len(LOGIN_BODY.encode('utf-8'))) req = urllib2.Request(LOGIN_URL, LOGIN_BODY, headers) resp = opener.open(req, timeout=15) return json.loads(resp.read()) jar = CookieJar() opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(jar)) payload = login(opener) print(json.dumps(payload, ensure_ascii=False)[:300])

几个参数单独说明。Content-Length用len(...encode('utf-8'))而不是len(...),是因为纯 ASCII 时两者相等、一旦参数里有中文就是字节数和字符数不等,服务端按字节数截断 Body,你的password可能只传过去一半,然后收到一个“密码错误”,排查半天还以为密文算错了。timeout=15是必须设的,不设的话遇到服务端挂起,脚本会一直卡在那儿,看不出是在等还是死了。Connection: Keep-Alive留着能省掉重复握手的开销,但对结果没影响。

LOGIN_BODY结尾那个&看着别扭,但很多服务端的分隔逻辑是按&切分再丢空段的,你删掉它一般没事,可一旦服务端的分隔实现是split('&')后不判空,就会在参数校验里报一个看不懂的错。既然抓包里带着,就留着。

最后有一个容易被忽略的点:如果抓包时看到 Content-Length 是 207,你手写 207,而实际拼出来的 Body 是 205 字节,服务端读满 207 字节才认为请求体完整,要么等超时、要么截断,报错信息五花八门。所以这个值永远让程序算。

Python 3 环境现在更常见,用 requests 重写一遍会短很多:

# Python 3:requests.Session 等价于 opener + CookieJar import requests s = requests.Session() s.headers.update({ 'User-Agent': 'Dalvik/1.6.0 (Linux; U; Android 4.1.1; M040 Build/JRO03H)', }) resp = s.post(LOGIN_URL, data=LOGIN_BODY, timeout=15) payload = resp.json()

requests 会自动算 Content-Length,也会自动解开 gzip,所以原来手工填的两处反而变成了负担。data=传的是字符串时按原样发,传字典时会自动 URL 编码并重排顺序;如果你需要严格保持参数顺序,就传字符串。Session 对象本身就是个持久的 Cookie 容器,s.post和后续的s.post共享同一份会话,不用再手动建 jar。

3.3 登录成功不能只看有没有返回内容

原始脚本里写的是if loginResult:,这行判断永远为真——只要 HTTP 状态是 200 并且响应体非空,Python 就认为它是真值,哪怕内容是{"status":0,"msg":"密码错误"}。这种判断方式会让你以为登录成功了,然后拿着空会话去拉数据,返回一堆“未登录”。

要看的是业务码:

def is_login_ok(payload): # 业务状态码优先,字段名可能是 status / code / result if payload.get('status') not in (1, '1', True): return False data = payload.get('data') or {} # 有的版本 status=1 但 data 是空的,也要判掉 return isinstance(data, dict) and bool(data)

怎么确定该看哪个字段?回到抓包工具的 RAW 视图,把“登录成功那一次”和“故意输错密码那一次”的响应并排看,找出只在成功时出现的那个键,就是你的判定依据。这个动作花不了两分钟,但能省掉后面一堆误判。

另外,登录响应里通常会带一份账号信息(学号、学校、昵称之类),把它打印出来跟抓包时的响应对照一眼,字段值和结构都对得上,才说明这条链路真的通了。我一般会在这一步停一下,把返回的 JSON 存成文件,后面写解析逻辑时当样本用。

4. JSON 解析与 timestamp 游标:把话题列表一页页翻到底

登录通了,下一个接口就是列表。移动端列表接口普遍不用页码,而是给一个时间戳或者一个自增 ID 当游标,服务端返回下一页的游标,你带着它再请求一次。这套机制看起来简单,实际写起来有三个地方会翻车:结构没摸清就取值、用递归翻页、字段缺失时整条丢掉。

4.1 先看结构再写取值路径

拿到一页响应,先别急着写for循环,把 JSON 打印出来看层级。这类列表接口的典型结构是这样的:

{ "status": 1, "data": { "timestampLong": 1490000000000, "messageBOs": [ { "messageId": 10001, "content": "一条示例话题内容", "schoolName": "示例学校", "issueTime": "2024-01-01 12:00:00", "studentBO": { "gender": 1 } } ] } }

值路径要记牢:条数在data.messageBOs,下一页游标在data.timestampLong,性别藏在data.messageBOs[i].studentBO.gender。这种嵌套两层的结构,最稳的取值方式是每一层都做一次空值兜底,别写json_data['data']['messageBOs']这种一杆子到底的写法——data为 null 的时候会直接抛TypeError,而你从堆栈里看不出是哪条数据的问题。

messageId是这个场景里的业务主键。话题列表会有重复返回的情况(翻页边界、服务端缓存),落库前按messageId去重比按内容去重靠谱得多,因为内容可能是空的,也可能是纯表情。

4.2 翻页用 while,别用递归

原始写法是个递归函数:拿到timestamp就调自己,直到status != 1才返回。逻辑上没错,但 Python 默认递归深度 1000 左右,页面一多就直接RuntimeError: maximum recursion depth exceeded。这种话题列表一页二十条,翻几千页也就是几万条数据,完全在正常量级内,所以递归必挂。

改成循环,顺便把三个终止条件都写全:

# -*- coding: utf-8 -*- import time TOPIC_URL = 'http://api.demo-edu.net/V2/Treehole/Message/getMessageByTopicIdV3.action' def iter_topics(session, topic_id=19, max_pages=500, interval=0.8): timestamp = 0 seen = set() for page in range(max_pages): 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) payload = session.post(TOPIC_URL, data=body, timeout=15).json() if payload.get('status') != 1: print('stop: status =', payload.get('status'), 'page =', page) break data = payload.get('data') or {} items = data.get('messageBOs') or [] next_ts = data.get('timestampLong') if not items: print('stop: empty page, page =', page) break if next_ts in seen: print('stop: timestamp not moving, page =', page) break seen.add(next_ts) for it in items: yield it timestamp = next_ts time.sleep(interval) else: print('stop: hit max_pages =', max_pages)

参数逐个说。max_pages=500是一颗后悔药,防止接口异常时无限跑下去烧流量。interval=0.8是请求间隔,翻页之间给服务端留点缓冲,也降低被风控盯上的概率。seen这个集合专门对付“游标不动”的情况:服务端偶尔会在没有下一页时原样返回上一层的时间戳,你不判这个,循环就会在最后一页反复请求同一个地址,跑一晚上存下几十万条重复数据,这个场景在抓包日志里看着像“还在正常翻页”,很容易被忽略。

for ... else那个分支在循环正常跑完(没被 break)时触发,用来提示是不是页数设小了。这个小细节在长跑脚本里挺有用,能让你一眼看出是抓完了还是被截断了。

4.3 字段清洗:别让一条空内容毁掉整页数据

原始代码里有这么一行:if each.get('content', False):。它想表达的是“有内容才处理”,但实际效果是content为空字符串时,get返回'',布尔判断为假,整条记录被跳过。如果你的目标是“落库但标记为空”,这样写就丢数据了;如果只是想跳过打印,那更不该放在数据收集这一层。

同样的坑还有studentBO。字段缺失或者为 null 时,each['studentBO']['gender']会抛KeyError或TypeError,一页二十条里只要有一条出问题,整批就没了。稳妥的抽取函数长这样:

def to_row(item): stu = item.get('studentBO') or {} return { 'message_id': item.get('messageId'), 'content': (item.get('content') or '').strip(), 'school': item.get('schoolName') or '', 'gender': stu.get('gender'), 'issue_time': item.get('issueTime'), }

or {}和or ''这种写法把 None 和缺失两种情况一起兜了,比写两层if简洁。注意(item.get('content') or '').strip()里的括号不能省,or的优先级比方法调用低,写成item.get('content') or ''.strip()就变成对空字符串取 strip 了。

落盘我一般用 JSONL,一行一条,追加写,中途挂了也不影响前面的数据:

import json with open('topics.jsonl', 'a', encoding='utf-8') as f: for item in iter_topics(s): row = to_row(item) if not row['message_id']: continue f.write(json.dumps(row, ensure_ascii=False) + '\n')

ensure_ascii=False必须加,否则中文全变成\uXXXX,虽然还能读回来但完全没法肉眼检查;encoding='utf-8'在 Python 3 里是默认值,明确写出来是给跨平台团队看的。写完拿wc -l数一下行数,跟抓包那一页的实际条数对一对,这一步后面还会再提。

5. 抓取链路的注意事项:六个翻车现场与对应排查手法

这套流程跑通一次不难,难的是跑稳。下面这六条是我在类似场景里反复撞过的,按现象、原因、解决三段记下来,遇到时对着症状查,比从代码第一行开始读快。

5.1 请求层:登录一直返回“账号或密码错误”

现象:用抓包里一模一样的账号密码密文,服务端返回账号或密码错误,但同一个账号在手机上能正常登录。

原因:三种可能,按概率排。一是 Header 不全,尤其是User-Agent和Host,缺了这两个,服务端或中间网关会按非客户端流量处理,直接给一个笼统的错误;二是Content-Length和实际 Body 字节数不符,服务端截断了 Body,password参数只剩前半截;三是参数顺序被改了,某些网关会按参数原始顺序做签名校验,你用字典传参被重排过就废了。

解决:先用抓包工具的“重放”功能验证密文本身没写错,把它自己的请求原样发一次,能通说明密文没问题,问题在 Python 侧。然后把 Header 字典打印出来,一项一项和 RAW 里的比,Accept-Encoding这类可以省,User-Agent、Host、Content-Type千万别省。最后确认 Body 是拼好的字符串而不是字典,长度用len(body.encode('utf-8'))现算。

5.2 请求层:明明有响应,json.loads却报解码错误

现象:ValueError: No JSON object could be decoded,打印出来的内容read()结果开头是一堆乱码字节。

原因:Header 里带了Accept-Encoding: gzip,服务端老老实实返回了 gzip 压缩过的 Body,而 urllib2 不会自动解压,你拿到的是压缩后的二进制,不是文本。

解决:两条路选一条。省事的做法是把Accept-Encoding从 Header 里删掉,服务端就不压缩了,代价是流量大一些,对采集场景影响可以忽略。想保留压缩的话,读出来自己解:

import gzip import io raw = resp.read() # 先判断是不是 gzip 头,魔数是 1f 8b if raw[:2] == b'\x1f\x8b': raw = gzip.GzipFile(fileobj=io.BytesIO(raw)).read() payload = json.loads(raw)

用 requests 就没这个问题,它会按响应头自动解压。这条我踩过一次,排查了半小时,因为错误信息里看不出任何跟压缩有关的东西。

5.3 会话层:登录成功,列表接口提示未登录

现象:登录响应正常,返回了用户信息,紧接着请求话题接口,status是 0 或者提示登录态失效。

原因:最常见的是 opener 不一致——登录用一个 opener,拉数据用urllib2.urlopen,两者不共享 Cookie。第二种是登录态其实在响应体里(token 形式),不在 Cookie 里,需要在后续请求的 Header 或 Body 中显式带上。第三种是多端登录,手机上重新打开了一次 APP,服务端把之前的会话踢掉了。

解决:确认登录和拉数据是同一个 opener 或同一个 requests.Session。然后对比抓包里“登录后的第一个列表请求”和“登录请求”的 Header 差异,找出多出来的那个键,大概率就是 token。最后,调试期间别在手机上频繁操作同一个账号,会话互踢会把问题伪装成代码 bug。

5.4 翻页层:第二页就停,或者无限循环

现象:脚本打印完第一页就status != 1;或者日志一直在刷“load successful”,但存下来的数据条数不涨。

原因:前者通常是timestamp没传对——初始值是0,第一页返回的timestampLong得带着去请求第二页,如果没取到或者取错了键,第二页会当成第一页请求,某些服务端会判定为重复请求直接拒绝。后者是游标没更新,一直用同一个timestamp循环。

解决:在第一页响应里print(data.keys())确认游标的实际字段名,不同版本可能叫timestampLong、nextCursor、lastId。然后在循环里打印每轮的timestamp和本页条数,一种一眼就能看出问题的排查方式。再加上前面那个seen集合,游标重复时主动退出。

5.5 解析层:跑一晚上存下来的数据少了一半

现象:抓包时一页能看到二十条,脚本跑完一页只存了十几条,也没有报错。

原因:each.get('content', False)这类写法在content为空字符串时把整条记录跳过了;或者某条数据的studentBO为 null,取值时抛了异常被外层的try吞掉,那条记录静默丢失。

解决:把过滤条件从“内容非空”改成“主键非空”,message_id缺失才跳过。取嵌套字段一律走or {}兜底。另外,在循环里加一个计数器,每页打印“本页收到 N 条,写入 M 条”,N 和 M 不相等就说明有丢数据,能立刻定位到是哪一页开始出的问题。

5.6 合规层:这套代码能跑,但不代表可以随便跑

现象:技术上跑通了,数据也能拿到,但心里没底。

原因:接口是别人的,数据是别人的,会话是模拟客户端拿到的。这类操作在不同场景下的边界差别极大,涉及个人信息、需要登录才能看到的内容,处理起来要格外谨慎。

解决:动手前先看目标服务的使用条款,确认允许抓取的范围;只取自己业务上真正需要的那几个字段,别把无关的个人信息一并落库;控制请求频率,别用并发打同一个账号;调试用的账号和真实用户数据分开。这些不是“技术上的坑”,但一旦越界,前面所有的技术工作都白费。

6. 断点续爬与抽样校验:让脚本第二次跑得比第一次快

脚本能跑通之后,真正决定它好不好用的是第二次运行。第一次跑挂了、或者数据看着不对想重跑,如果每次都从timestamp=0开始,前面几千页就白抓了,而且重复数据还得再洗一遍。

我一般会把游标落盘:每页处理完,把当前的timestamp写进一个状态文件,下次启动先读它。

import json import os STATE = 'cursor.json' def load_cursor(): if os.path.exists(STATE): with open(STATE, 'r', encoding='utf-8') as f: return json.load(f).get('timestamp', 0) return 0 def save_cursor(ts): tmp = STATE + '.tmp' with open(tmp, 'w', encoding='utf-8') as f: json.dump({'timestamp': ts}, f) os.replace(tmp, STATE) # 原子替换,避免写一半断电把状态搞坏

这里用临时文件加os.replace而不是直接覆盖,是为了防止中途崩溃留下一个空的 JSON,下次启动读的时候直接抛异常。几十行的脚本也值得这么写,因为状态文件坏掉之后,你只能从头再来。

搭配seen集合还有一个用法:把已经抓到的message_id也存一份,重跑时先加载,循环里遇到就跳过。这样即便游标因为某些原因回退了,也不会重复落库。

校验环节我习惯做个抽样对照,做完这一步才敢说数据是完整的:

检查项对照方法不一致说明什么
单页条数抓包那页的messageBOs长度 vs 脚本打印条数解析层丢了数据
首条内容抓包 RAW 里的第一条 vs 落库的第一条字段映射写错了
游标推进每页打印的timestamp是否单调变化翻页逻辑有问题
总条数跑完后跟 APP 里显示的楼层数大致比对终止条件设早了

这个对照表做一次就够了,后面改代码再跑,只看第一行和第三行。

最后说个习惯。这类接口采集最怕的就是“跑通了就不管了”,接口一改版,脚本会在某个凌晨静默失败,第二天你看到的是一个数据没更新的报表,而不是一条报错。所以我每次上这类脚本,都会在关键位置留日志:登录后的业务码、每页的游标和条数、终止原因。日志比断点更重要,因为断点只告诉你停在哪儿,日志才告诉你为什么停。从那以后我每次改这类脚本,都会先跑三页、把日志从头到尾读一遍、确认终止原因是“抓完了”而不是“被拒绝了”,再放到后台去。希望帮到你。

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

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

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

立即咨询