☰
告别Selenium:纯Python+Requests实现微信视频号扫码登录拿Cookie
2026/10/5 7:30:18 网站建设 项目流程

前两个月接了个自动化小项目,目标很直接:把微信视频号管理后台的登录Cookie稳定拿下来,供后续的批量数据任务使用。第一版我偷懒,直接上了Selenium——浏览器自动打开、人工扫码、再抓取Cookie,本地Windows上跑得欢。结果一挪到云服务器,问题全冒出来了:内存动不动400MB往上,跑两三天必崩,Selenium和Chrome版本一不对付又得重新调。气得我干脆重写,用纯Python + Requests把整套扫码登录流程模拟了一遍,效果反而出奇地好,登录从十几秒压缩到三秒以内不说,内存占用可以忽略不计。这篇文章把方案原理、完整代码和调试时踩的坑一次讲清楚,给还被Selenium折腾的兄弟们一条新路子。

1. 触发我重写的那个夜晚:Selenium在服务器上的崩溃

先说清楚场景:微信视频号助手(channels.weixin.qq.com)是视频号创作者的后台,里面有作品数据、粉丝分析、评论管理,但官方并没有给个人开发者提供一套公开的、可以直接调用的数据API。想批量拉自己账号的数据,最现实的办法就是先登录拿到Cookie,然后带着Cookie去请求后台的接口。

这个需求听起来简单,但一开始我被“自动化登录”这四个字带偏了,第一反应就是上Selenium。毕竟社区里一搜全是这类教程,浏览器自动化好像才是正统解法。结果在生产环境跑了一周,我整个人都被它磨得没脾气。

1.1 一套流程串起来要走十几秒,内存却吃掉400MB

Selenium本质上会拉起一个真实的浏览器内核,不管你有没有头,它都跑了一整套Chromium。视频号助手这个后台页面本身又不轻,登录进去之后要加载各种图表、实时数据面板和前端框架,一个无头浏览器实例轻松吃掉300到500MB内存。

在我那个只有2G内存的云服务器上,单账号跑勉强能撑住,一旦加上数据抓取、定时调度、日志回传这些模块,系统负载直接拉满。最崩溃的是连续跑两三天后,Chrome进程偶尔会僵在那里不退出,内存一点一点泄漏,最后整个服务被系统OOM杀掉。

除了内存,版本匹配也是个无底洞。Selenium要跟Chrome版本严格对应,Chrome一升级,chromedriver版本不匹配,代码直接跑不起来。服务器上又不敢随便“自动升级”,每次发版都要手工锁版本,维护成本远超我第一次写登录脚本的时间。

对比下来,Requests方案的优势非常直观:

对比项Selenium方案Requests方案
内存占用300~500MB几乎可忽略
启动时间8~15秒0.5秒以内
外部依赖Chrome + chromedriver + Selenium库requests + qrcode
维护成本三套版本互相匹配只需关注接口是否改版
适合场景复杂页面交互登录取Cookie、调接口

如果你的目标只是“拿到登录态然后去调接口”,用Selenium有点杀鸡用牛刀的意思,而且这头牛还特别难伺候。

1.2 扫码登录其实是个异步轮询,浏览器是被大材小用的

很多人没意识到,扫码登录这件事根本不需要浏览器渲染页面,它在网络层面其实是一个标准的异步轮询模型:

  1. 脚本请求服务器,获取一个临时会话标识(通常叫uuid)
  2. 把带uuid的链接编码成二维码,展示给用户
  3. 用户拿起手机扫码、确认
  4. 脚本每隔一两秒问一次服务器:这个人扫码了没
  5. 服务器回答:已经确认,并返回一个跳转地址
  6. 脚本访问这个跳转地址,服务端通过Set-Cookie下发登录态Cookie

整个过程没有复杂的DOM操作,没有JS渲染,没有CSS布局,纯粹就是“请求-响应-轮询-再请求”。浏览器在这里唯一的“功劳”是帮你画了个二维码,但这个用Python的qrcode库一样能干,而且画得又快又好看。

理清了这层逻辑,你会发现Selenium已经没有任何存在的必要。Requests能搞定全部网络请求,qrcode能搞定二维码展示,剩下的就是按顺序把请求串起来。

2. 手机确认登录的那几秒里,HTTP世界发生了什么

写代码之前必须把原理吃透,否则接口一变你就抓瞎。这章我用大白话把扫码登录背后发生的事情讲清楚,理解了这些,你再看代码就是顺水推舟的事。

2.1 二维码里藏的不是密码,是一个待确认的uuid

二维码的内容看起来是一串乱码URL,但实际上它不包含你的账号密码,只有一个临时生成的uuid参数。这个uuid是整个登录会话在服务端的唯一标识,相当于一张“未签名的工单”。手机微信扫码后,会识别出这串URL,发现这是微信开放平台的登录确认请求,于是把当前登录的微信身份跟这个uuid绑定起来。

这一步只是“绑定”,还没真正授权。你会在手机上看到“确认登录”的页面,点击之后,服务端才把这张工单标记为“已确认”。所以整个流程的安全性建立在两个关键点上:一是uuid存活时间极短,通常几分钟就过期;二是必须由手机端主动确认,服务器才会放行。

用餐厅排队等位来类比最直观:uuid是你手里的叫号牌,手机扫码相当于告诉服务员“我是这个号”,点确认才是“我确认要排这个号”。服务员(服务器)只看叫号牌,叫到号了(状态变更),才会让你进包厢(下发Cookie)。

2.2 状态轮询:前端定时器和后端接口的默契

用户扫码之后,网页怎么知道“该跳转了”?靠的是轮询。前端JavaScript里通常会有一个setInterval定时器,每隔1到2秒向后端的状态查询接口发一次请求,传的参数还是那个uuid。

后端拿到uuid后,会返回一个状态码,大致就三种情况:

  • 未扫码,继续等
  • 已扫码但没确认,提示用户在手机上操作
  • 已确认,返回下一步跳转地址

这个机制在Requests里实现起来极其简单,把setInterval换成while循环加sleep就行。前端定时器还在为页面卡顿焦虑的时候,你已经用一个普通循环把同样的事干完了。

需要提醒的是:轮询的本质是“主动问”,不是你跟服务器之间维持了一条长连接。所以间隔太短没有任何收益,只会增加服务端压力,还容易被限流。间隔太长则用户等得着急。实测下来1.2到1.8秒是个比较舒服的区间。

2.3 登录态Cookie是怎么从跳转链路里长出来的

当用户在手机上点了确认,轮询接口返回的数据里会带一个redirect_url,这个地址很关键。它通常包含一次性票据,相当于服务端给你开了一张“临时通行证”。你用Requests去访问这个地址时,服务端会在响应的Set-Cookie头里下发真正的登录态Cookie。

这里就显露出requests.Session的价值了。Session对象会自动记录响应里的Set-Cookie,并按域名、路径、过期时间帮你管理Cookie。你要做的只是拿着同一个Session继续访问平台首页,后续请求就自动带上登录凭证了。

所以整个登录链路里,最核心的就三步:拿uuid、轮询确认、访问跳转地址收Cookie。每一环都不需要浏览器参与,纯HTTP请求就能完整跑通。

3. 代码落地:一个覆盖扫码到保存Cookie的完整登录模块

原理讲完,进入正题。我会给出一份可以直接跑的代码,然后在后面逐段拆解关键逻辑。这不是“只能跑通演示”的玩具代码,里面包含了重试机制、超时控制、登录态复用等生产环境需要的细节,照着改一改就能接入你自己的项目。

3.1 只用两个依赖,环境准备一句话

安装命令非常简单:

pip install requests "qrcode[pil]"

这里解释一下为什么用qrcode[pil]:qrcode库负责生成二维码,保存为PNG图片时需要依赖pillow这个图像库。后面那个[pil]后缀是qrcode的extras写法,会帮你把pillow一起装上。如果你不装pillow,跑qrcode.make(...).save(...)这行代码时会直接报错,报错信息还不容易联想到是缺库。这是第一个小坑,后面避坑章节还会展开。

Python版本建议3.8以上,用到的语法和库都很基础,新老版本兼容性没什么大问题。

3.2 完整代码:video_login.py

""" video_login.py 微信视频号助手登录 Cookie 获取,纯 Python + Requests 实现。 依赖:requests, qrcode[pil] 用法: python video_login.py # 优先复用本地 Cookie,失效则重新扫码 python video_login.py --login # 强制重新扫码登录 """ import json import random import re import sys import time from pathlib import Path import qrcode import requests # ---------------------- 配置区(以实际抓包为准) ---------------------- LOGIN_PAGE = "https://channels.weixin.qq.com/platform/login" # 二维码内容通常指向这个确认页 CONFIRM_PAGE = "https://open.weixin.qq.com/connect/confirm" # 登录轮询接口,改版后请从 Network 面板重新抓取 LP_CONFIRM_API = "https://lp.open.weixin.qq.com/connect/confirm" COOKIE_FILE = "wx_channels_cookies.json" QR_FILE = "wx_channels_qrcode.png" QR_TIMEOUT = 300 # 二维码有效期,单位秒 # 轮询状态码,以页面 JS 实际定义为准 SCAN_WAITING = 1 SCAN_CONFIRMED = 2 SCAN_SUCCESS = 3 # -------------------------------------------------------------------- class ChannelsLogin: def __init__(self): self.session = requests.Session() self.session.headers.update({ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), "Accept-Language": "zh-CN,zh;q=0.9", }) self.cookie_file = Path(COOKIE_FILE) def _get(self, url, *, referer=None, params=None, retries=3, **kwargs): """GET 请求封装:统一带 Referer,并处理 429 限流""" headers = {"Referer": referer} if referer else {} for attempt in range(retries): try: resp = self.session.get( url, headers=headers, params=params, timeout=15, **kwargs ) if resp.status_code == 429: wait = 2 ** attempt + random.uniform(0.3, 1.0) print(f"[429] 触发限流,{wait:.1f}s 后重试") time.sleep(wait) continue resp.raise_for_status() return resp except requests.RequestException as exc: if attempt == retries - 1: raise RuntimeError(f"请求失败: {url} -> {exc}") from exc time.sleep(2 ** attempt + random.uniform(0.2, 0.8)) raise RuntimeError(f"重试 {retries} 次仍然失败: {url}") def get_qr_content(self): """第一步:从登录页解析二维码内容""" resp = self._get(LOGIN_PAGE) html = resp.text # 不同版本登录页注入的数据结构不同,常见字段:codeUrl / qrUrl / uuid patterns = [ r'"codeUrl"\s*:\s*"([^"]+)"', r'"qrUrl"\s*:\s*"([^"]+)"', r'"uuid"\s*:\s*"([^"]+)"', ] for pattern in patterns: match = re.search(pattern, html) if match: value = match.group(1) if value.startswith("http"): return value return f"{CONFIRM_PAGE}?uuid={value}" # 兜底:直接从 HTML 里捞形如 .../connect/confirm?uuid=xxx 的链接 url_match = re.search( r"https://[a-z0-9.-]*weixin[a-z0-9.-]*\.qq\.com/connect/confirm\?uuid=[a-zA-Z0-9_-]+", html, ) if url_match: return url_match.group(0) raise RuntimeError( "没有从登录页解析到二维码内容,页面结构可能改版,请抓包更新" ) @staticmethod def parse_uuid(qr_content): match = re.search(r"uuid=([a-zA-Z0-9_-]+)", qr_content) if not match: raise ValueError(f"二维码内容里没有 uuid: {qr_content}") return match.group(1) def poll_login_state(self, uuid, timeout=QR_TIMEOUT): """第二步:轮询等待扫码确认,返回跳转地址""" print(f"二维码已生成: {QR_FILE},请用手机微信扫码({timeout}s 内有效)") deadline = time.time() + timeout while time.time() < deadline: resp = self._get( LP_CONFIRM_API, params={"uuid": uuid, "t": int(time.time() * 1000)}, referer=CONFIRM_PAGE, ) data = resp.json() status = data.get("status") if status == SCAN_CONFIRMED: print("[提示] 已扫码,请在手机上点击确认") elif status == SCAN_SUCCESS: print("[成功] 已确认登录") for key in ("redirect_url", "redirect", "url"): if data.get(key): return data[key] return LOGIN_PAGE # 1.2~1.8s 随机间隔,避免固定频率被限流 time.sleep(random.uniform(1.2, 1.8)) raise TimeoutError("等待扫码超时,请重新运行脚本") def finalize_login(self, redirect_url): """第三步:访问跳转地址,收集 Set-Cookie,返回完整 Cookie 字典""" self._get(redirect_url, referer=LOGIN_PAGE) # 再访问一次平台首页,确认 Cookie 在真实请求链路里生效 self._get(LOGIN_PAGE, referer=redirect_url) return self.session.cookies.get_dict() def save_cookies(self, cookies): self.cookie_file.write_text( json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"Cookie 已保存到 {self.cookie_file}") def load_cookies(self): if not self.cookie_file.exists(): return None cookies = json.loads(self.cookie_file.read_text(encoding="utf-8")) if cookies: self.session.cookies.update(cookies) return cookies or None def is_logged_in(self): """检查本地 Cookie 是否还有效""" resp = self._get( LOGIN_PAGE, params={"t": int(time.time() * 1000)}, retries=1, ) url = resp.url.lower() if "login" in url: return False if "platform/home" in url or "platform/index" in url: return True print(f"[未知] 当前 URL: {resp.url},请人工判断登录态") return False def login(self): qr_content = self.get_qr_content() uuid = self.parse_uuid(qr_content) qrcode.make(qr_content).save(QR_FILE) redirect_url = self.poll_login_state(uuid) cookies = self.finalize_login(redirect_url) self.save_cookies(cookies) return cookies if __name__ == "__main__": cli = ChannelsLogin() if "--login" not in sys.argv and cli.load_cookies(): if cli.is_logged_in(): print("本地 Cookie 仍有效,直接复用") print( json.dumps(cli.session.cookies.get_dict(), ensure_ascii=False, indent=2) ) sys.exit(0) print("本地 Cookie 已失效,重新扫码登录") cli.login() print("登录完成,Cookie 内容:") print(json.dumps(cli.session.cookies.get_dict(), ensure_ascii=False, indent=2))

代码里配置区的接口路径是我当时从浏览器Network面板抓的,微信这类型接口改版比较频繁,如果你跑的时候发现解析不到二维码或轮询没反应,优先按第4章的方法重新抓包,把新接口路径填到配置区就行。

3.3 关键逻辑逐段拆解

这份代码里最值得说的地方,是几个容易被忽略的小细节。

第一,构造函数里用了requests.Session(),这是一个隐形的Cookie罐子。Session会在后续请求中自动携带之前收到的所有Cookie,也会自动处理响应里的Set-Cookie。如果不用Session而用裸的requests.get,你就要手工管理Cookie的存取,登录链路一复杂必出问题。

第二,统一维护User-Agent和Accept-Language。很多服务端接口会校验请求头,UA不是浏览器标识、Accept-Language缺失,轻则返回异常页面,重则直接拒绝请求。建议直接从自己浏览器的Network面板复制一份完整的UA字符串,而不是用requests默认的python-requests/x.x。

第三,_get方法里的429处理逻辑。这里做了两层:一是遇到429状态码时等待并重试,二是遇到网络异常时按指数退避重试。429就是服务端告诉你“请求太频繁了”,此时必须停下来等一等,而不是继续猛冲。重试间隔用2 ** attempt递增,再叠加一个随机数,避免多个重试请求在同一时刻撞上去。

第四,get_qr_content用正则从登录页HTML里提取二维码内容,而不是硬编码某个接口。这样做的原因是登录页里通常会以JS变量的形式注入初始状态,但变量名在不同版本里可能是codeUrl、qrUrl或者uuid。多写几个正则去匹配,总有一个能中,比写死一种格式健壮得多。

第五,轮询间隔不是固定值,而是random.uniform(1.2, 1.8)。固定间隔会让请求呈现出明显的时钟规律,风控系统很容易识别这种机器行为。随机抖动一下,请求模式更接近真人操作,也降低了整体频率。

第六,finalize_login里为什么要访问两次:第一次访问redirect_url是为了拿Set-Cookie,第二次访问LOGIN_PAGE是为了验证这些Cookie在真实页面请求链路上是否生效。有些时候只访问一次redirect_url,Cookie虽然拿到了但没在实际业务域下“激活”,再访问一次首页才能把完整登录态固化下来。

4. 实测踩过的坑:从429限流到Cookie失效,五个绕不开的问题

代码只是第一步,真正耗时间的是调试。下面这五个坑我全部踩过,每个都值得单独拿出来说,因为它们在真实项目里几乎必然会出现。

4.1 轮询太激进,被429按在地上摩擦

第一个版本我把轮询间隔设成了0.5秒,想着能快点拿到结果,结果跑了不到十次轮询,接口就开始返回429状态码。响应体里就是那句经典的429 Too Many Requests,服务端限流了。

这里要理解限流不是bug,它是服务端的自我保护机制。视频号这种平台背后是海量真实用户,任何单个客户端都不应该以极短间隔连续请求同一个接口。0.5秒的轮询频率,在服务端看来基本就是机器特征。

解决办法就是我代码里写的:间隔至少1秒往上,我最终调到1.2到1.8秒的随机区间,连续跑多个账号也没再触发限流。还有个建议:如果登录流程失败需要重试,不要立刻重跑整个脚本,等上十几秒再开始,让服务端的状态稍微缓一缓。

4.2 保存了一堆Cookie,请求还是跳回登录页

这是我调试过程中最困惑的一个问题。流程跑完了,Cookie也存成JSON文件了,但下次脚本启动时带着Cookie去请求首页,还是被302重定向到登录页。

排查下来有几个原因:

一是保存时机不对。有些人会在轮询确认后立刻保存Cookie,但此时跳转地址还没访问,服务端最关键的那几个Set-Cookie还没下发,保存的自然是不完整的。必须等finalize_login执行完,完整走完重定向链路,再用cookies.get_dict()导出。

二是Cookie的作用域问题。requests.Session.cookies.get_dict()默认返回该Session管理的所有Cookie,但不同Cookie可能对应不同domain。如果你在代码里手工过滤、只挑了几个“看起来有用”的字段,很可能会漏掉真正关键的凭证。我建议直接保存完整的字典,不做过多的手工筛选。

三是最隐蔽的一点:浏览器里有些Cookie是前端JavaScript写入的,不走Set-Cookie头。requests是纯HTTP客户端,没有JS执行环境,拿不到这种Cookie。要避免这个问题,就得保证登录链路完全走服务端的Set-Cookie,不要依赖任何页面JS伪造Cookie的流程。访问完整的redirect_url,就是为了把所有该由服务端下发的Cookie都激活。

4.3 qrcode图片在服务器上打不开,二维码白生成了

本地Windows上跑得好好的,qrcode生成PNG后自动打开,扫码完事。一到Linux服务器,代码照样跑,PNG也生成了,但我人不在服务器旁边,总不能每次登录都远程下载图片吧。

这里有两个解法。

第一个是用qrcode库的终端ASCII输出功能,直接在终端打印出二维码的字符画。新版qrcode库支持print_ascii方法:

qr = qrcode.QRCode(border=1) qr.add_data(qr_content) qr.make(fit=True) qr.print_ascii(invert=True)

这样你SSH登录服务器后,终端里直接就能看到二维码,用手机对着屏幕扫即可。注意invert=True在部分终端上显示效果更好,如果扫不出来就试试把invert去掉。

第二个是生成PNG后,把文件放到你临时搭建的一个静态目录下,手机浏览器访问图片地址再扫。这个方式更适合有公网环境的服务器,但要注意扫完码记得删掉图片文件,避免二维码被别人扫走导致账号风险。

另外提醒一句,如果报错ModuleNotFoundError: No module named 'PIL',就是安装时没带[pil]后缀,补装一下pillow即可。

4.4 Cookie里的登录态说没就没,程序却毫无察觉

登录态有效期不是永久的,短则几小时,长则几周,取决于平台策略。最糟糕的情况不是Cookie失效,而是你的程序不知道它失效了,还在拿着过期的Cookie拼命请求业务接口,返回的全是登录跳转页面,解析半天解析出一堆破烂数据。

所以我把登录态检测做成了独立的方法is_logged_in:请求平台首页,观察最终URL。如果URL里出现login,说明被踢回登录页了;如果停留在platform/home或platform/index之类的地址,说明Cookie还有效。

在主程序里,每次启动任务前先走一遍这个检测,失效了再自动拉起登录流程。这属于典型的“吞掉异常不如提前预防”,成本低,效果立竿见影。

4.5 想并发跑多个账号,结果一起被风控

登录逻辑跑通后,我以为可以放心并发多个账号了,就写了个多线程脚本同时轮询多张二维码。结果没过多久,所有账号的轮询请求全部开始报429,有的甚至出现长时间超时。

原因不复杂:多个登录会话共用同一个出口IP,每个会话都在高频轮询,叠加起来对服务端就是一波集中的请求洪峰。风控系统看到同一个IP下有多个uuid同时在疯狂轮询,直接把该IP的请求降权了。

解决思路是错峰。不同账号的登录流程不要在同一时刻启动,人为错开几秒到十几秒;每个账号的轮询间隔也随机化,避免整齐划一的请求曲线。如果账号数量多,建议给每个账号分配独立的Session,不要共用一个Session实例。

5. 拿Cookie做后续开发前,先把这层“自愈机制”搭好

登录模块的价值不只在于跑通一次,而在于它能不能稳定地反复使用。我建议你拿到代码后,先把持久化和自愈机制调通,再接业务逻辑。否则上线之后天天半夜爬起来扫码,那体验比Selenium还酸爽。

5.1 JSON持久化和自动加载

代码里的save_cookies和load_cookies已经把持久化逻辑写好了。Cookie以JSON格式存到本地文件,下次启动时load_cookies会读取文件并更新Session里的Cookie记录。

这里有两个细节可以优化。一是文件路径用相对路径时要考虑工作目录问题,建议改成绝对路径,或者跟脚本所在目录绑定。二是Cookie文件里全是敏感凭证,一定要加入.gitignore列表,防止误提交到代码仓库。我习惯把Cookie文件权限设置为当前用户可读写,不给其他用户任何权限。

5.2 登录态失效后的自愈循环

有了is_logged_in方法,就可以写一个简单的自愈循环:

def run_with_relogin(job_func): cli = ChannelsLogin() if not cli.load_cookies() or not cli.is_logged_in(): cli.login() while True: try: job_func(cli.session) except LoginExpiredError: print("登录态失效,重新扫码登录") cli.login() time.sleep(60)

原理很简单:每次执行业务任务前,或者捕获到登录失效异常时,先检查再处理。如果本地Cookie失效,就重新拉起扫码登录流程。注意扫码登录依然需要人工参与,所以真正的“无人值守”在扫码场景里是不存在的。你只能尽量把Cookie有效期拉满,把重新扫码的频率降到最低。

5.3 请求节奏控制:一个简单的限速器

登录之后的业务请求同样需要注意节奏。视频号后台的接口虽然没有特别严苛的限制,但短时间大量请求一样会触发限流。我的做法是在Session的请求层加一个轻量的延时控制器:

class RateLimiter: def __init__(self, min_interval=1.0, max_interval=2.0): self.min_interval = min_interval self.max_interval = max_interval self._last_request_time = 0 def wait(self): now = time.time() elapsed = now - self._last_request_time interval = random.uniform(self.min_interval, self.max_interval) if elapsed < interval: time.sleep(interval - elapsed) self._last_request_time = time.time()

每次请求前调用rate_limiter.wait(),就能保证两次请求之间至少隔了一段时间,并且间隔是随机的。这个思路和登录轮询里的随机抖动是同一个道理,核心就是“别让请求序列表现出太强的机器规律”。

最后说一句关于Cookie安全的题外话:视频号登录Cookie就是你访问管理后台的完整凭证,拿到它基本等于拿到了这个账号后台的访问权。不管你是把自己的脚本分享给朋友,还是在技术社区提问,务必先把Cookie内容脱敏再发出去。我见过有人直接把Cookie贴到工单里,结果第二天账号被异地登录,非常危险。

另外还有个小技巧:如果二维码过期了,程序提示超时,不需要杀掉进程重启,直接在代码里捕获TimeoutError后重新调用一次login()即可。二维码有效期通常是5分钟,超过这个时间必须重新生成。把这一步做成循环,用户体验会好很多。扫码登录这件事,远没有想象中那么玄乎,理解了它的HTTP本质之后,用Requests实现反而是最舒服的解法。

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

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

立即咨询