做本地生活数据统计或者个人订单归档的人,大概率都卡在同一个地方:页面抓取、订单解析、报表生成这些逻辑早就写顺了,偏偏在登录页上过不去。我前后折腾过好几轮,从最早的 Cookie 手工粘贴,到后来用浏览器自动化模拟点击,最后落到了「手机短信登录 + 登录态复用」这套组合拳上。这篇就聊聊某外卖/本地生活服务平台的自动登录解决方案,核心是围绕手机短信登录这条链路,把验证码怎么拿到手、时序怎么控制、登录态怎么保存和失效怎么探测讲透。适合三类人:写个人订单归档脚本的、做自动化测试要反复跑登录流程的、以及想理解验证码类登录自动化通用套路的同学。下面的代码都是骨架级,选择器需要你自己对着当时的页面替换,但流程和坑我尽量讲全。
1. 需求拆解:为什么登录这一步值得单独做一套方案
1.1 三类人的诉求其实完全不同
先别急着写代码,把「谁在用、用到什么程度」想清楚,方案的复杂度会差一个数量级。
第一类是个人数据归档型。每天/每周跑一次,把自己账号里的订单、评价、红包记录导出来,做记账或者消费分析。这种人最大的特点是频率极低、账号只有一个、容忍度高——哪怕登录失败要人工干预一次,也能接受。所以方案可以很「土」:登录态手动抓一次,存成文件,能用多久用多久。
第二类是自动化测试型。被测系统就是自己的业务,需要频繁构造不同账号的登录态,验证登录分支、异常流程、回归用例。这类诉求对稳定性要求高,但账号是自己的测试账号池,短信可以走自建的测试号码,可控性反而最好。
第三类是流程演示/教学型。要录屏、要做 Demo、要给非技术同事演示一遍完整链路。这类对「过程可见」要求高,headless 反而不好,需要有人能看懂的日志和暂停点。
把这三类分清楚之后你会发现,真正需要「全自动、高频、多账号」的场景其实很少,而这类场景恰恰是最容易触碰到平台风控和用户协议的。所以我后面给的主线方案是:登录态复用为主,短信自动化兜底,频率压到最低。这不是技术做不到更快,而是没必要为了一点便利去冒账号风险。
1.2 登录是整个自动化链路里唯一的「人工依赖点」
为什么单独给登录做方案?因为其余环节都是幂等的、可重试的、纯本地计算的,而登录是一次外部依赖强、有时效窗口、有次数限制的操作。具体难在四个地方:
- 验证码有时间窗:通常 5 分钟内有效,超过就得重发,重发又有频率限制。
- 验证码在另一个设备上:它不在你的服务器上,而在你手机里,这是天然的跨设备通信问题。
- 页面结构不稳定:登录页恰恰是平台改版最勤、A/B 实验最多、风控埋点最密的地方。
- 失败代价高:登录失败次数多了会触发风控,轻则要求滑块,重则临时锁定,比不登录还麻烦。
所以 Login 模块的设计目标不应该只是「能跑通」,而应该是**「尽量减少触发次数、失败可观测、失败后可恢复」**。这三条比跑通本身重要得多。我见过太多脚本,跑通一次之后再也没跑通第二次,问题都出在后两条上。
2. 方案选型:四条路线,怎么挑
2.1 四种方案横向对比
市面上讲「自动登录」的方案大致分四档,从轻到重排列:
| 方案 | 实现方式 | 首次成本 | 长期维护成本 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| A. 登录态复用 | 人工登录一次,导出 Cookie/Token 并持久化 | 极低 | 低(只需偶尔刷新) | 高 | 单账号、低频、个人归档 |
| B. 浏览器自动化 + 短信自动化 | Playwright/Selenium 驱动页面 + 程序读取短信 | 中 | 中(选择器会变) | 中 | 需要反复构造登录态、自动化测试 |
| C. 移动端 UI 自动化 | Appium/UIAutomator2 驱动 App + 读取本机短信 | 高 | 高(App 版本迭代快) | 中偏低 | 只存在于 App 侧的流程、无 Web 入口 |
| D. 协议层逆向 | 直接构造登录接口请求 | 极高 | 极高(签名算法随时变) | 低 | 不建议,风险与收益不匹配 |
我把 A 作为默认,B 作为必要时的补充,C 只在没有 Web 入口时才考虑,D 直接划掉。
2.2 为什么主力放在「登录态复用 + 短信兜底」
理由很实在。第一,登录态复用的成本几乎为零,Playwright 的storageState一行代码就能存能读,Selenium 序列化 cookies 也是现成的。第二,它对平台最友好——一次登录,后续请求都带着合法会话,行为模式和真人浏览器完全一致,不产生额外的登录请求,风控压力最小。第三,它把「最不稳定的验证码环节」从日常执行路径里挪出去了,只在会话过期时才触发一次。
那为什么还要保留短信自动化?因为登录态一定会过期。过期的触发条件很多:服务端主动失效、改了密码、异地登录、长时间不活跃、平台做了强制重新认证。如果只有方案 A,每次过期都得人工介入,脚本就变成了「半自动」,长期跑不起来。加上短信自动化之后,过期能自愈,才算是闭环。
一个容易被忽略的细节:不要把短信自动化当成日常路径。我见过有人在每次请求前都重新登录一遍,理由是「保险」。这完全是反的——反复登录才是最大的风险源。正确做法是把登录收敛成一个「补登录」动作,由过期探测来触发,一天最多一两次。
2.3 短信接收端的三种取法
这是整个方案的核心难点,展开说。目标很简单:让运行脚本的那台机器知道「刚才那条短信里的 6 位数字是什么」。
取法一:安卓本机监听 + 转发。手机端起一个服务,监听短信广播,收到后 POST 到一个局域网内的 HTTP 接口。这种方式延迟最低(通常 1~3 秒),可靠性最好,缺点是需要一台常开的安卓手机,以及要处理系统的后台限制。
取法二:系统级短信转发到邮箱/IM。手机系统自带的「短信转发」功能,或者用自动化工具(如快捷指令)把短信内容转发到自己的邮箱或自建的消息服务。这种方式零代码,缺点是有转发延迟,且内容格式会被转发通道加工。
取法三:手动粘贴兜底。别笑,这是最实用的兜底。当自动化链路挂了,脚本暂停并打印提示,人去手机上看一眼,把验证码粘贴到终端。加这 5 行代码,能让你的脚本少挂 80% 的次数。
我的实际组合是:取法一为主,取法二为备,取法三兜底。三层降级,基本不会被卡死。
注意:这里讨论的短信读取,前提是手机号是你自己的、设备是你自己的、账号也是你自己的。任何涉及他人号码、批量号码、租用号码的做法都不在讨论范围内,也不建议尝试,既有隐私风险也有合规风险。
3. 核心技术点拆解
3.1 页面定位:别用绝对路径,用语义和角色
登录页的元素定位,新手最容易犯的错是用浏览器右键「复制 XPath」拿到一串又长又脆的路径,形如/html/body/div[3]/div/div[2]/form/div[2]/input[1]。这种路径活不过一次改版,因为登录页经常做 A/B 实验,加个埋点 div、换个布局容器,路径就全错位了。
我更推荐三层定位策略,按优先级降级:
- 语义角色定位:Playwright 的
get_by_role("textbox", name="手机号")或者get_by_placeholder,这类定位基于可访问性树,改版时只要文案不变基本不受影响。 - 稳定属性定位:
>{ "saved_at": 1730000000, "expire_hint": 1730086400, "account_tag": "personal-a", "fpHint": "ua-hash", "state": { "cookies": [], "origins": [] } }为什么要包一层?因为光靠 cookies 的 expires 判断不了真实有效期。有些会话 cookie 是 session 类型,浏览器关了就没;有些是服务端滑动过期,cookie 上的时间只是本地标记。所以我用「时间兜底 + 探测验证」双保险:
- 时间兜底:超过
expire_hint就不再使用,直接走重登,省一次无效请求。 - 探测验证:每次启动时访问一个需要登录的轻量接口(比如个人资料页),如果被重定向到登录页或者返回未授权状态码,就判定失效。
探测接口的选择很关键,要挑轻量、稳定、有明确未登录特征的。首页不行,因为未登录也能看;下单页不行,因为有副作用。个人中心/账户信息这类页面最合适。判定逻辑建议写成「状态码 + URL 关键字 + 页面特征元素」三者取或,任一命中即认为失效,误判成本很低(多登一次而已),比漏判强。
3.5 风控与降频:写得像人比写得快重要
这一段是纯经验,没有标准答案。我的几条原则:
- 固定 UA + 固定指纹:不要每次启动随机 UA,反而更像机器人。同一账号长期用同一套浏览器指纹,是正常用户的行为特征。
- 保留真实视口和语言:默认的
1280x720加英文 locale 在本地生活类站点上很不自然,设成常见的手机/桌面分辨率、zh-CN,成本几乎为零。 - 登录频率压到最低:登录态能用就不登,能撑 7 天绝不每天登。我给自己定的硬上限是每账号每天不超过 2 次成功登录。
- 失败退避:连续失败 2 次就停下来,指数退避,而不是立刻重试第 3 次。
- 不做无意义的反检测对抗:不搞鼠标轨迹模拟、不搞 Canvas 指纹伪造。理由很简单,这类手段一旦被识别,风险比不搞更大,而且维护成本极高。真正的稳定来自「低频 + 行为合理」,不是来自技术对抗。
注意:如果你在自动化过程中遇到了滑块、点选这类人机校验,正确做法是停下并转人工,而不是想办法自动通过。这在任何平台的用户协议里都是明确的红线,硬闯的代价是账号被封。
4. 完整实操:从零跑通一次短信自动登录
4.1 环境准备与依赖
Python 3.10 以上,装三个包:
playwright、flask、loguru(日志)。浏览器装chromium就够了。pip install playwright flask loguru playwright install chromium手机端如果不是用系统自带转发,需要装一个能监听短信并 POST 的轻量工具,跑在同一个局域网里。手机和电脑必须在同一网段,PC 侧记下自己的局域网 IP,把接收服务监听在
0.0.0.0上,不然手机 POST 过来会连不上(这个坑我踩过一次,排查了半小时才发现是绑在了127.0.0.1)。4.2 短信接收端:一个 60 行的本地服务
先写接收端。核心逻辑就三件事:提取、加锁写入、抛事件。
# sms_hub.py import re import time import threading from flask import Flask, request, jsonify app = Flask(__name__) _lock = threading.Lock() _event = threading.Event() _latest = {"code": None, "ts": 0.0, "raw_len": 0} # 4~6 位纯数字,前后不能紧邻数字,避免抓到订单号/客服电话的一部分 CODE_RE = re.compile(r"(?<!\d)(\d{4,6})(?!\d)") def _extract(body: str): # 优先取短信前半段(验证码通常在开头) head = body[:60] m = CODE_RE.search(head) or CODE_RE.search(body) return m.group(1) if m else None @app.post("/sms") def recv(): payload = request.get_json(silent=True) or request.form body = (payload.get("body") or "").strip() if not body: return jsonify(ok=False, msg="empty body"), 400 code = _extract(body) if not code: return jsonify(ok=False, msg="no code found"), 200 with _lock: _latest["code"] = code _latest["ts"] = time.time() _latest["raw_len"] = len(body) _event.clear() # 新码到达,重置事件 _event.set() # 唤醒等待方 return jsonify(ok=True) def wait_code(after_ts: float, timeout: float = 90.0): """阻塞等待一个 after_ts 之后产生的验证码""" deadline = time.time() + timeout while time.time() < deadline: with _lock: if _latest["code"] and _latest["ts"] > after_ts: return _latest["code"], _latest["ts"] _event.wait(timeout=1.0) # 1 秒轮询兜底,防事件丢失 _event.clear() return None, None if __name__ == "__main__": # 注意绑定 0.0.0.0,否则手机侧请求打不进来 app.run(host="0.0.0.0", port=8848, threaded=True)这段代码有两个设计点值得说。一是
_event同时用于唤醒和轮询兜底:_event.wait(timeout=1.0)表示「有事件立刻醒,没事件最多等 1 秒」,这样既不浪费 CPU 又不会因为事件丢失而卡死。二是wait_code接收after_ts参数,只接受点击之后产生的短信,从根上解决了「读到上一次登录的旧验证码」问题。手机侧要发的请求就这么简单:
curl -X POST http://192.168.1.x:8848/sms \ -H "Content-Type: application/json" \ -d '{"body":"【某平台】您的登录验证码是 936214,5分钟内有效。"}'4.3 Playwright 侧主流程
主流程我拆成三步:打开登录页、切换到短信登录、提交并等待。中间的关键是点击时刻打时间戳。
# login_flow.py import time import json from pathlib import Path from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout from sms_hub import wait_code from loguru import logger STATE_FILE = Path("state/account_a.json") LOGIN_URL = "https://example.com/login" # 替换为实际登录入口 HOME_URL = "https://example.com/profile" # 用于登录态探测 UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") def do_login(page, phone: str): page.goto(LOGIN_URL, wait_until="domcontentloaded") # 1) 切换到短信登录 tab(文案可能是"短信登录"/"验证码登录",两个都试) for label in ("短信登录", "验证码登录"): tab = page.get_by_text(label, exact=False) if tab.count() > 0: tab.first.click() break else: raise RuntimeError("没找到短信登录入口,页面可能改版") # 2) 填手机号(跳过国家区号输入框,只填主输入框) page.get_by_role("textbox").filter(has_not_text="区号").first.fill(phone) # 3) 点击获取验证码,记录点击时刻 click_ts = time.time() page.get_by_text("获取验证码", exact=False).first.click() logger.info("已点击获取验证码,开始等待短信") # 4) 等待验证码 code, ts = wait_code(after_ts=click_ts, timeout=90) if not code: raise TimeoutError("90 秒内未收到验证码,请人工介入") logger.info(f"收到验证码,延迟 {ts - click_ts:.1f}s") # 5) 填入并提交 page.locator('input[placeholder*="验证码"]').first.fill(code) page.get_by_text("登录", exact=True).first.click() # 6) 等待跳转,用弱条件判断(URL 变化 或 出现用户信息) try: page.wait_for_url(lambda u: "login" not in u, timeout=20_000) except PWTimeout: if page.get_by_text("用户").count() == 0: raise RuntimeError("提交后未见登录成功特征,可能触发了人机校验") logger.info("登录成功")代码里有几处刻意的写法,解释一下。
get_by_text(label, exact=False)用模糊匹配,是因为登录 tab 文案经常是「短信登录」「短信验证码登录」这一组变体,模糊匹配能覆盖。但按钮的「登录」用了exact=True,因为登录页上「登录」二字的出现频率极高,模糊匹配会点到别的东西上(比如「登录即代表同意《协议》」那段文字)。同一个页面上,宽松匹配和严格匹配要分开用,这是踩坑出来的直觉。wait_for_url(lambda u: "login" not in u)用的是弱条件,因为登录成功后跳转的目标 URL 可能是首页、可能是个人中心、可能带一堆参数,硬编码目标 URL 一定翻车。判定「URL 里不再含 login」是个很稳的近似。4.4 状态保存、探测与自愈调度
主流程之上再包一层调度,才算完整。逻辑是:先探测,能用就直接用,不能用才登录。
def is_alive(page) -> bool: try: resp = page.goto(HOME_URL, wait_until="domcontentloaded", timeout=15_000) if resp and resp.status in (401, 403): return False if "login" in page.url: return False # 页面特征:出现「退出登录」说明是登录态 return page.get_by_text("退出登录").count() > 0 except Exception as e: logger.warning(f"探测异常,按失效处理:{e}") return False def ensure_login(phone: str) -> Path: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"]) ctx_args = { "user_agent": UA, "locale": "zh-CN", "viewport": {"width": 1440, "height": 900}, "timezone_id": "Asia/Shanghai", } if STATE_FILE.exists(): ctx_args["storage_state"] = str(STATE_FILE) ctx = browser.new_context(**ctx_args) page = ctx.new_page() if STATE_FILE.exists() and is_alive(page): logger.info("登录态有效,跳过登录") else: logger.info("登录态失效,进入补登录流程") do_login(page, phone) STATE_FILE.parent.mkdir(parents=True, exist_ok=True) ctx.storage_state(path=str(STATE_FILE)) logger.info(f"登录态已保存:{STATE_FILE}") browser.close() return STATE_FILEensure_login就是那个「补登录」入口。日常任务只需要ensure_login之后复用STATE_FILE,不再反复登录。这里加--disable-blink-features=AutomationControlled只是去掉了最表层的自动化标记,我不建议继续叠加更多伪装参数,理由前面讲过——低频比伪装有用。4.5 跑通验证要看什么
第一次跑通之后,我建议在日志里固定打三行信息,后面排查全靠它们:
已点击获取验证码 → 收到验证码,延迟 4.2s → 登录成功- 延迟:正常在 2~15 秒。如果经常超过 30 秒,说明转发链路有问题(手机后台被杀、网络切换、转发工具休眠),不是代码问题。
- 状态来源:日志要区分「命中缓存登录态」和「走了补登录」,否则你会误以为系统很稳,其实每天都在重登。
- 失败类型:把异常分成「未收到短信」「元素未找到」「提交后未跳转」「人机校验」四类,打不同标签。标签化之后,跑一周就能看出主要矛盾在哪。
5. 常见问题与排查实录
5.1 高频问题速查表
现象 最可能原因 排查动作 处理方式 点了获取验证码但收不到短信 手机后台被系统清理,转发服务挂了 用 curl 手动 POST 一条测试短信到 /sms把转发工具加入电池白名单 收到短信但脚本读不到 接收服务绑在 127.0.0.1,手机连不上 netstat -an看监听地址改成 0.0.0.0读到的是上一次的旧码 没有按点击时间戳过滤 看日志里 code 的 ts 与 click_ts 关系 加 after_ts过滤提取到的不是验证码 正则抓到了订单号/客服电话 打印短信前 60 字看结构 收紧正则,加上下文锚点 元素定位失败 登录页改版或 A/B 实验 截图 + 打印 page.content()片段改用语义定位,保留多套选择器 提交后一直不跳转 触发了滑块或人机校验 截图看是否出现校验组件 立刻停止,转人工 登录态明明有效却被判失效 探测页有重定向或懒加载 打印最终 URL 与命中元素 用 401/403 + URL 双条件判定 连续几天都走补登录 cookie 生命周期很短,或有设备校验 对比 storage_state里 cookie 的 expire预定刷新窗口,每天固定时间刷一次 5.2 几个踩过的坑
坑一:验证码按钮的倒计时会让「再次点击」失败。有些脚本失败重试时会立刻再点一次,但按钮上还挂着 60 秒倒计时,点击无效,脚本就卡在那里直到超时。处理方式是先判断按钮文案是否含「重新获取」或「秒」,含就说明在倒计时,直接进入等待,不要再点。
坑二:短信里的验证码可能是 4 位也可能是 6 位。我一开始正则写死
\d{6},结果某个流程发的是 4 位码,怎么都匹配不上。改成\d{4,6}之后又有新问题——正文里的「有效期 5 分钟」会被抓成5,但因为有前后断言和「优先取前 60 字」,实际没出问题。稳妥起见,建议把匹配到的所有候选都打日志,观察几天再确定规则。坑三:headless 模式下的字体渲染差异导致文本定位失败。极少数情况下,headless 下页面文案的渲染和 headed 有细微差别(比如省略号、不可见字符),
get_by_text严格匹配会失败。遇到这种情况我会用exact=False加has_text组合,而不是去改渲染参数。坑四:多账号并发登录时,短信接收端会串码。如果你有多个账号要登录,且共用一台手机收短信,那
_latest这种单槽位设计一定串。解决办法不是加数据库,而是串行化——同一时刻只允许一个登录流程在等码,用一个全局锁排队。并发登录本身也不是好习惯,风险远大于收益。坑五:状态文件里带着不该带的东西。
storage_state会保存全部 cookies,包括一些统计、广告相关的。这不影响功能,但文件泄漏的影响面更大。我的做法是保存前过滤一遍,只保留必要域下的必要字段,并在文件权限上设成600。提示:所有异常路径上都应该有一条「转人工」的出口。脚本卡死不如脚本报错,脚本报错不如脚本暂停等你粘贴验证码。可运维性远比「全自动」重要。
6. 合规边界与长期维护
6.1 只碰自己的账号,这条线不能模糊
这套方案从设计上就限定在「自己的账号、自己的设备、自己的号码、自己的数据」这个范围内:低频、单账号、登录态复用为主、遇到人机校验就停。这几条不是技术限制,是主动的自我约束。
越界的做法有很多,比如批量账号、号码池、绕过人机校验、高频登录刷接口——这些不仅技术维护成本极高(平台风控是持续迭代的),而且明确违反平台规则,账号被封是迟早的事。更要紧的是,一旦涉及他人号码和他人数据,性质就变了,不再是技术问题。
另外提醒一句:读取订单数据用于个人记账是合理的,但拿到数据后不要二次分发、不要公开数据集。自己看和给别人看,是两件事。
6.2 抗变化的写法:把易变的部分集中到一处
登录页一定会变。所以代码结构上要做一件事:把所有和页面强相关的信息集中到一个配置文件里,主流程只引用符号名。
# selectors.py LOGIN_URL = "https://example.com/login" PROFILE_URL = "https://example.com/profile" SMS_TAB_TEXTS = ["短信登录", "验证码登录", "手机验证码登录"] PHONE_INPUT = {"role": "textbox", "name_re": r"手机号|手机|账号"} CODE_INPUT = {"css": 'input[placeholder*="验证码"]'} SEND_CODE_TEXTS = ["获取验证码", "发送验证码"] SUBMIT_TEXTS = ["登录", "立即登录"] LOGGED_IN_MARKERS = ["退出登录", "我的订单"]这样登录页改版时,你只需要改这一个文件,主流程一行不动。看起来是小事,但当你有三四个账号、两三个环境的时候,这个约定的价值会立刻显现。我给自己的规矩是:主流程里不允许出现任何字面量文案。
还有一个长期维护的习惯:每周固定跑一次「健康检查」,不做任何业务操作,只做「读登录态 → 探测是否有效 → 打日志」。这样你能提前发现失效趋势,而不是等业务任务跑到一半才崩。这个检查跑起来只要几十秒,但能省掉大量事后排查。
最后分享一个我一直在用的小技巧:给登录态文件命名带上保存日期,比如
account_a_20250110.json,只保留最近两份。这样你随时能看出来「上次成功登录是什么时候」,也能在出问题时快速回滚到上一个可用状态。比在日志里翻时间戳直观得多。 - 时间兜底:超过