每天早上打开电脑第一件事,就是点开那个视频 App 的签到页,点一下,领一点成长值,攒够了换几天会员——这事儿我连续干了小半年,直到某次出差连续断签,几个月攒下来的连续天数清零,那一刻我决定把它交给脚本。这篇就聊聊怎么用 Python 3.8 加 ChromeDriver,把"某长视频平台"的每日成长值签到做成一个稳定跑半年不用管的自动化任务。下文统一把目标站点叫作"目标平台",不点名,道理你懂。核心关键词先摆出来:Python 3.8、Selenium、ChromeDriver、自动签到、Cookie 复用、显式等待、定时任务——这七样凑齐,整套东西才立得住。这篇适合两类人看:一类是刚学完 Python 基础语法,想找个真实小项目练手的;另一类是已经写过几行爬虫,但脚本跑三天就挂、每次都得手动改的。前者能拿到一套完整可跑的代码骨架,后者能拿到我踩过的那些坑和对应的兜底方案。全文的代码都在 3.8 上验证过,新版本大概率也能跑,但别指望 3.6 那种老环境,光是类型提示和 f-string 的一些行为差异就够你调半天。
1. 需求拆解与技术选型:为什么这套组合能跑得住
动手写第一行代码之前,得先把"签到"这件事拆开看。表面上是"打开网页、点个按钮",实际拆下来是四件事:拿到有效登录态、稳定地找到那个按钮、判断点完之后到底成没成、以及一年三百六十五天每天都准时执行。这四件事里,只有第二件是"点击"本身,剩下三件全是工程问题。很多人第一次写这种脚本,上来就写driver.find_element_by_xpath(...).click(),跑通一次觉得成了,结果第二天早上起来发现脚本报NoSuchElementException,原因可能是弹窗盖住了、可能是登录态过期了、也可能是页面改版了。所以选型的出发点不是"哪个最简单",而是"哪个在页面变化和账号状态变化面前最能自愈"。
1.1 签到脚本真正的难点在哪三处
第一处难点是登录态。签到必须登录,而登录要过验证码、要过滑块、要过设备指纹,这条路我不想碰,也不建议你碰——不是技术做不到,是投入产出比太差,而且一旦触发平台的账号安全策略,代价远大于签到那点成长值。正确做法是人工登录一次,把浏览器里的 Cookie 导出来存盘,之后每次启动把 Cookie 塞回去。这一步的工程细节比想象中多,Selenium 塞 Cookie 有几个字段会直接报错,后面第三章会专门讲。
第二处难点是页面结构的不确定性。签到页通常会叠加这些东西:首次进入的新手引导蒙层、节日活动弹窗、会员续费广告、App 下载提示条。你写的那个 XPath 明明在调试时好用,第二天就点不到了,八成是被某个弹窗盖住了。所以定位元素之前必须先做一轮"清场",把所有可能存在的遮罩层、关闭按钮、蒙层都尝试关掉,关不掉也不能让程序崩。
第三处难点是结果判定。点了按钮不等于签到成功,可能是"今日已签到"、可能是"活动未开始"、可能是"签到失败请重试"。脚本必须能区分这三种状态,并且对第三种做重试、对第二种直接退出、对第一种记一笔日志。没有结果判定的脚本等于没有脚本,你永远不知道它到底是真跑了还是空转了一天。
提示:签到类自动化只用于个人账号的每日打卡,不要做多账号批量、不要做高频轮询、不要试图绕过平台的任何安全校验。控制频率、尊重站点规则,这是底线,也是脚本能长期存活的前提。
1.2 直连接口和浏览器自动化,到底选哪个
这是选型环节最常被问的问题,我的答案很明确:先看接口好不好拿,拿不到就老老实实用浏览器。直连的意思是不启动浏览器,用 requests 直接往业务接口发请求,优点是快、省资源、几十毫秒完事。但它有个致命前提——你得把请求里的鉴权参数完整复现出来。现在主流平台的前端请求基本都带签名,签名的生成逻辑往往被打包、混淆、还会随版本变化,你逆向出来的算法可能两周后就失效,维护成本极高。
浏览器自动化的思路完全不同:它跑的是一个真实的 Chrome,页面上的 JS 正常执行,签名自然生成,Cookie 自动携带,你只需要模拟人的点击动作。缺点是重——启动一次浏览器吃几百兆内存,跑一次要十几秒。但签到这件事一天只跑一次,十几秒完全可以接受。用一点资源换稳定性,这笔买卖很划算。
还有一个我常用的折中方案,值得单独拎出来说:用浏览器只负责"打开页面、完成登录态加载",签到动作本身通过execute_async_script在页面上下文里用fetch发起。因为 fetch 是从页面里发出的同源请求,Cookie、Referer、UA 全都是浏览器原生的,你只需要在控制台里抓到那个接口的 URL 和请求体,把它原样搬进脚本就行,完全不用管签名怎么算。这个方案比纯点击快,比纯 requests 稳,代码量还小。
1.3 版本清单与环境对号入座
版本对不齐是新手最常见的第一道坎,尤其是 Chrome 和 ChromeDriver 的匹配。从 Chrome 115 开始,官方把驱动发布方式改了,老的驱动下载页只保留到 114,之后统一走新的分发渠道,Selenium 4.6 以后的版本内置了驱动管理器,能自动识别本机 Chrome 版本并下载匹配的驱动。所以在 2024 年之后,绝大多数情况下你不需要手动下驱动了,但知道它在干什么仍然必要——网络不通、公司代理拦截的时候,自动下载会失败,你还是得手动补。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.8.x(3.8.10 最稳) | 3.8 已停止安全更新,本地练手没问题,长期跑建议升到 3.10+ |
| selenium | 4.9 ~ 4.15 | 4.6+ 才有 Selenium Manager,太新的版本对 3.8 支持在收缩 |
| Chrome | 与驱动同大版本 | 大版本号必须一致,小版本可以不同 |
| ChromeDriver | 跟随 Chrome | 优先让 Selenium 自动管理,失败再手动指定路径 |
| 操作系统 | Windows 10/11、Ubuntu 20.04+、macOS | 三端代码一致,差异只在定时任务那一节 |
这里多说一句 Python 版本的事。3.8 现在已经是 EOL 状态,官方不再发安全补丁,新装的机器我一般直接上 3.10 或 3.11。之所以标题里写 3.8,是因为相当一部分单位的办公电脑、旧笔记本上装的就是 3.8,而且不少教程和第三方库的最低要求也卡在这里。如果你是自己新配环境,没必要为了对齐教程特意降级;如果只能用 3.8,注意别用 3.9 之后才有的语法,比如字典合并运算符|、内置泛型list[str]这种,运行时直接报TypeError,报错信息还特别不直观。
2. 从零搭环境:让第一行代码真的跑起来
环境搭建这一段我不想写成一堆命令的罗列,因为真正卡人的从来不是"装什么",而是"装完了为什么还报错"。所以这一章按"能跑起来"倒推:先建隔离环境,再解决驱动,最后用一段最小可运行脚本做验证。每一步都有对应的验证动作,前一步没验证通过就别往下走,不然错误会层层叠加,最后你连问题出在哪一层都分不清。
2.1 虚拟环境与依赖安装的正确姿势
强烈建议用虚拟环境,不要往全局 Python 里装包。理由很实在:Selenium 的版本在不同项目之间经常冲突,全局装了一个版本,另一个项目就跑不起来,而卸载重装的过程中你可能会把系统里其他依赖弄坏。虚拟环境本质上是给每个项目复制一份独立的包目录,互不干扰,出了问题直接删掉重建,一分钟的事。
Windows 下打开命令行,进到你准备放项目的目录,执行下面三行。注意py -3.8里的版本号要换成你本机实际装的版本,不确定就先跑py -0看一眼列表。
py -3.8 -m venv venv venv\Scripts\activate python -m pip install --upgrade pipLinux 和 macOS 的路径不一样,激活方式也不同:
python3.8 -m venv venv source venv/bin/activate python -m pip install --upgrade pip激活成功的标志是命令行提示符前面多了(venv)。这一步没看到括号,后面所有 pip 安装都会装到全局去,等于白折腾。装依赖的时候如果下载慢,可以临时指定一个国内镜像源,但不要把它写死进 requirements 文件里——换台机器、换个网络环境,写死的源反而会变成新的故障点。
pip install "selenium>=4.9,<4.16"只装 Selenium 就够了,不需要额外装webdriver-manager之类的第三方驱动管理包,Selenium 4.6 之后自带的能力已经覆盖了这件事,多装一个包就多一处版本冲突的可能。装完跑一下pip show selenium确认版本号落在范围内。
2.2 ChromeDriver 版本匹配的自动化与手动兜底
先说自动这条路。Selenium 4.6 以后,你只要写webdriver.Chrome(),不传任何参数,它内部会做三件事:找到本机 Chrome 的可执行文件、读出版本号、去官方分发渠道下载对应大版本的驱动,缓存到用户目录下。整个过程对你是透明的,第一次运行会慢几秒(要下载),之后就快了。
手动兜底适用于三种情况:公司网络对下载域名做了限制、本机 Chrome 是多版本共存导致识别错误、以及你想把驱动固定下来避免某天自动更新后行为变化。手动操作分两步,先查 Chrome 版本,浏览器地址栏输入chrome://version,第一行那个版本号,取前三段就够,比如120.0.6099.130,你只需要120。然后去驱动的官方分发页面找到对应大版本的压缩包,下载解压,把可执行文件放到项目目录下的drivers/文件夹里,代码里显式指定路径。
from selenium import webdriver from selenium.webdriver.chrome.service import Service from pathlib import Path DRIVER_PATH = Path(__file__).resolve().parent / "drivers" / "chromedriver.exe" service = Service(executable_path=str(DRIVER_PATH)) driver = webdriver.Chrome(service=service)如果你在 Linux 上跑,可执行文件没有后缀,而且要给执行权限chmod +x chromedriver,这一步漏了会报Permission denied,报错信息看起来像是路径不对,很容易误导人。
注意:驱动和浏览器的大版本必须一致,119 的驱动配 120 的浏览器会直接起不来,报
session not created: This version of ChromeDriver only supports Chrome version 119。看到这个报错别去改代码,去查版本。
2.3 一段最小可运行骨架与验证方法
环境弄好之后,先别急着写签到逻辑,用一段十行的脚本验证"浏览器能被程序拉起来"这件事。这一步能跑通,说明 Python、Selenium、驱动、浏览器四者之间是通的,后面所有问题都只可能是业务逻辑问题,排查范围一下子缩小了很多。
from selenium import webdriver from selenium.webdriver.chrome.options import Options import time options = Options() options.add_argument("--start-maximized") options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) driver = webdriver.Chrome(options=options) try: driver.get("https://www.example.com") print("标题:", driver.title) print("当前地址:", driver.current_url) time.sleep(2) finally: driver.quit()--disable-blink-features=AutomationControlled这一行是让navigator.webdriver这个标识不被暴露,属于基础的兼容处理,不是用来对抗什么,纯粹是因为有些页面的前端逻辑会对这个标识做分支处理,导致渲染出来的 DOM 和你手动打开时不一样,增加定位难度。excludeSwitches那行是去掉地址栏上"Chrome 正受到自动测试软件的控制"的提示条,纯粹为了截图好看,对功能没影响。
验证标准很简单:脚本跑完,浏览器自动打开、加载页面、打印出标题、然后自动关闭,没有报错堆栈。如果浏览器根本没弹出来,先检查是不是被杀毒软件拦了;如果弹出来又立刻关掉,八成是驱动版本不匹配;如果卡在driver.get不动,多半是网络或者 DNS 问题。
3. 登录态保持:Cookie 复用才是长期跑的关键
这是整套方案里最核心的一章,也是最多人做错的地方。我看过不少所谓"自动签到脚本",里面赫然写着自动填账号密码、自动识别验证码,先不说这么做合不合规,光是"账号密码明文存在脚本里"这一条就已经很危险了。正确且省事的做法只有一条:人工登录一次,把 Cookie 存下来,之后每次复用。
3.1 为什么不碰自动登录这条路
从技术角度讲,自动登录要解决的问题包括但不限于:图形验证码识别、滑块轨迹模拟、短信验证码接收、设备指纹一致性、登录频次限制。每一样都是一个独立的深坑,而且平台的防护手段一直在迭代,你今天调通的方案,下个月可能就完全失效。更麻烦的是,频繁的异常登录行为可能触发账号安全机制,轻则要求二次验证,重则临时限制登录,为了每天几个成长值付出这个代价,完全不划算。
Cookie 复用的思路是把这件事降维:登录动作由你自己在浏览器里完成,脚本只负责"接管一个已经登录的会话"。脚本要做的只有两件事——把 Cookie 序列化存到本地文件,以及下次启动时把它反序列化塞回浏览器。整个过程不涉及任何账号密码,本地文件泄露的风险也远小于密码泄露,而且 Cookie 通常有过期时间,风险窗口是有限的。
从工程角度看,Cookie 复用的另一个好处是"失败可恢复"。Cookie 过期了,脚本会检测到未登录状态,这时候它可以直接调用input()暂停,让你在弹出的浏览器窗口里手动扫码登录一下,登录完按回车,脚本继续往下跑,同时把新的 Cookie 覆盖保存。这套兜底逻辑写起来不到二十行,但能把脚本的可用周期从"几天"拉长到"几个月"。
3.2 Cookie 的落盘与回填,以及那三个必踩的坑
先看保存。登录完成之后,driver.get_cookies()返回的是一个列表,每个元素是一个字典,包含 name、value、domain、path、expiry、secure、httpOnly、sameSite 这些字段。直接json.dump存盘看起来没问题,但回填的时候会翻车。翻车点有三个,我挨个说。
第一个坑是sameSite字段。Chrome 导出的值可能是"no_restriction"、"unspecified"这类内部表示,而 Selenium 的add_cookie只认"Strict"、"Lax"、"None"三个值,传别的直接抛InvalidArgumentException,报错信息完全没提是哪个字段的问题,非常难查。解决办法是在回填前把这个字段过滤掉,反正服务端也不会因为缺这个字段就拒绝你。
第二个坑是expiry的类型。JSON 序列化再反序列化之后,这个值有时候会变成字符串,而 Selenium 要求是整数时间戳,类型不对同样报参数错误。稳妥做法是显式转一次int(),转不了就丢掉这个字段。
第三个坑是域名的前置点。有些 Cookie 的 domain 是.example.com这种带前导点的形式,而add_cookie对当前页面域名有校验,域名对不上会报InvalidCookieDomainException。规避方式是先driver.get到目标域名下的任意一个页面,让当前会话的域和 Cookie 的域对齐,然后再逐个添加。
import json from pathlib import Path COOKIE_FILE = Path(__file__).resolve().parent / "cookies.json" DROP_FIELDS = {"sameSite", "storeId", "id"} def save_cookies(driver): cookies = driver.get_cookies() COOKIE_FILE.write_text( json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8" ) return len(cookies) def load_cookies(driver, home_url): if not COOKIE_FILE.exists(): return 0 cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8")) driver.get(home_url) # 先落到目标域,否则域名校验不过 ok = 0 for ck in cookies: item = {k: v for k, v in ck.items() if k not in DROP_FIELDS} if "expiry" in item: try: item["expiry"] = int(item["expiry"]) except (TypeError, ValueError): item.pop("expiry") try: driver.add_cookie(item) ok += 1 except Exception as e: print("跳过一条 Cookie:", ck.get("name"), e) driver.refresh() # 关键:加完必须刷新,Cookie 才生效 return ok最后那个driver.refresh()是必须的,很多人卡在这一步——Cookie 明明加进去了,但页面还是未登录状态,就是因为加 Cookie 之后没有重新请求。浏览器加载页面时用的是请求发出那一刻的 Cookie 快照,后续通过 API 添加的 Cookie 不会自动应用到已经渲染的页面上。
3.3 怎么判断登录态失效,以及人工兜底怎么做
判断登录态不能靠猜,要找一个"只有登录后才会出现"的锚点。可以是头像元素、可以是用户名文本、可以是某个专属接口返回的状态码。我用得最多的是请求一个需要登录的轻量接口,看返回体里的状态位,这种方式比找 DOM 元素稳,因为 DOM 会随改版变化,接口的状态约定相对稳定。
def is_logged_in(driver): try: driver.set_script_timeout(10) script = """ var cb = arguments[arguments.length - 1]; fetch('/api/user/info', {credentials: 'include'}) .then(r => r.json()) .then(d => cb(d)) .catch(e => cb({error: String(e)})); """ data = driver.execute_async_script(script) return bool(data.get("data") and data["data"].get("uid")) except Exception: return False接口路径当然要你自己抓包确认,这里的/api/user/info只是示意。判定为未登录之后,不要直接退出,走人工兜底:把浏览器窗口留在前台,打印一行提示,等用户扫码登录,登录完成后按回车,脚本重新保存 Cookie 并继续执行。
if not is_logged_in(driver): print("登录态已失效,请在打开的浏览器中手动登录,完成后回到这里按回车") input() save_cookies(driver)这套逻辑看起来土,但它把"需要人参与"这件事变成了一次按键,而不是一次改代码。整个脚本跑了半年,真正需要我介入的次数不到十次,而且每次都是十秒钟解决。
4. 页面操作细节:定位、等待与签到动作的完整实现
到了这一章,环境通了、登录态有了,剩下的就是"怎么把那个按钮点下去并确认点成功了"。听起来是最简单的一步,实际上手会发现,八成的调试时间都花在这里。原因不复杂:页面是动态渲染的,元素出现的时机不确定,遮罩层的出现条件不确定,而你的代码是线性的。
4.1 元素定位的优先级:为什么 id 永远排第一
定位方式的选择有一个明确的优先级顺序,按"稳定性"从高到低排:第一是id,第二是自定义的>def safe_find(driver, candidates, timeout=10): """按优先级依次尝试多个定位方式,返回第一个找到的元素""" from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException for by, value in candidates: try: return WebDriverWait(driver, timeout).until( EC.presence_of_element_located((by, value)) ) except TimeoutException: continue return None
把候选定位做成列表传进去,这个模式在实际项目里非常有用。它让"页面改版导致定位失效"这件事从"脚本崩溃"降级为"多花十秒尝试下一个候选",鲁棒性提升是数量级的。
4.2 显式等待与重试封装,别再写 sleep 了
time.sleep(5)是新手最爱用的等待方式,也是最容易被骂的。它的问题是双向的:页面三秒就加载完了,你白等两秒;页面八秒才加载完,你等五秒直接报错。正确的做法是显式等待——让 WebDriver 每隔一小段时间检查一次条件,条件满足立刻返回,超时才报错。
常用的等待条件有几种:元素出现在 DOM 里(presence_of_element_located)、元素可见(visibility_of_element_located)、元素可点击(element_to_be_clickable)、元素消失(invisibility_of_element_located)。选哪个要看场景,比如"等弹窗消失"就得用 invisibility,用 presence 会立刻返回(因为元素还在,只是被隐藏了),然后你下一步就去点,结果点到遮罩上。
注意:
element_to_be_clickable不只是判断可见,还额外要求元素尺寸大于零、没被禁用。在动态渲染的页面上,用它比用visibility更稳,能避免"元素可见但点不动"的那类诡异问题。
点击动作本身也需要包装重试。常见失败有两种:一是ElementClickInterceptedException,意思是元素被别的层挡住了,Selenium 拒绝点击;二是StaleElementReferenceException,意思是元素在你拿到它之后被页面重新渲染替换掉了,句柄失效。这两种都不该让程序退出,正确做法是短暂等待后重新查找元素再点。
def robust_click(driver, by, value, retries=3): last_err = None for i in range(retries): try: el = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((by, value)) ) driver.execute_script( "arguments[0].scrollIntoView({block:'center'});", el ) time.sleep(0.4) el.click() return True except Exception as e: last_err = e time.sleep(1.5) print("点击失败:", last_err) return FalsescrollIntoView那行是关键的细节。很多签到按钮在页面中部偏下,如果不在可视区域内,Selenium 的点击会失败或者点偏。先滚动到视口中央再点,成功率会高很多。滚动之后加一个 0.4 秒的停顿也是有必要的,因为滚动本身是个动画过程,滚动还没结束就点,位置会算错。
4.3 从进入页面到确认签到成功的完整流程
把前面所有零件拼起来,一次完整的签到流程大概是这样的:加载首页并回填 Cookie、刷新、判断登录态、跳转到签到页、清理弹窗遮罩、定位签到按钮、点击、等待结果文本出现、根据文本判断结果、截图存档、关闭浏览器。
清理弹窗这一段我通常写成一个独立的函数,把常见的关闭按钮选择器都列成候选,逐个尝试,找到就关,找不到就跳过,全程不抛异常。因为弹窗的出现是完全不可预期的,有时候有有时候没有,有时候有两个,所以这里必须是"尽力而为"而不是"必须成功"。
POPUP_CLOSERS = [ (By.CSS_SELECTOR, "[class*='close']"), (By.CSS_SELECTOR, "[aria-label='close']"), (By.XPATH, "//div[contains(@class,'mask')]//i"), (By.XPATH, "//span[text()='我知道了']"), (By.XPATH, "//button[contains(text(),'知道了')]"), ] def clear_popups(driver, rounds=2): for _ in range(rounds): hit = False for by, val in POPUP_CLOSERS: try: for el in driver.find_elements(by, val): if el.is_displayed(): driver.execute_script("arguments[0].click();", el) hit = True time.sleep(0.5) except Exception: continue if not hit: break用driver.execute_script("arguments[0].click()", el)代替el.click()是有讲究的:JS 点击不受"元素是否被遮挡"的限制,能穿透一些 Selenium 认为不可点击的场景。当然它也有代价,就是如果元素本身绑定了事件委托或者依赖真实鼠标事件,JS 点击可能不触发,这时候还得退回原生点击。所以我的习惯是常规点击为主,遇到拦截异常时用 JS 点击兜底。
结果判定这一段,最好用的不是看按钮文案,而是看页面上的状态提示。签到成功的页面通常会有一个"今日已签到"或"签到成功 +N"的提示,同时按钮会变成禁用态。判定时把几种可能的关键词都列进来,做一次模糊匹配,比精确匹配某个元素更抗改版。
SUCCESS_KEYS = ["已签到", "签到成功", "明日再来", "连续签到"] FAIL_KEYS = ["失败", "重试", "异常", "稍后再试"] def judge_result(driver): page = driver.page_source for k in SUCCESS_KEYS: if k in page: return "success" for k in FAIL_KEYS: if k in page: return "fail" return "unknown"page_source拿的是渲染后的完整 HTML,用它做关键词判断虽然粗糙,但在"只关心成功还是失败"这个粒度上足够用,而且几乎不会因为样式改版而失效。真正需要精确数据的时候再去做结构化解析,日常签到用这个就够了。
5. 把它变成能长期跑的东西:日志、截图、调度与资源控制
代码能跑通只是及格线,能不能连续跑一个月不出事才是分水岭。这一章讲的全是"让它活着"的工程手段,看起来和签到业务没关系,但实际决定了你是每天手动跑一次,还是三个月后想起来才去看一眼。
5.1 日志体系与失败现场截图
先说日志。print在本地调试时够用,但计划任务跑起来之后你根本看不到控制台输出,这时候没有日志等于两眼一抹黑。Python 自带的 logging 就够,关键是配好格式和轮转,别让日志文件无限增长把磁盘撑爆。
import logging from logging.handlers import RotatingFileHandler from pathlib import Path LOG_DIR = Path(__file__).resolve().parent / "logs" LOG_DIR.mkdir(exist_ok=True) def setup_logger(): logger = logging.getLogger("checkin") logger.setLevel(logging.INFO) fmt = logging.Formatter( "%(asctime)s | %(levelname)-7s | %(message)s", "%Y-%m-%d %H:%M:%S" ) fh = RotatingFileHandler( LOG_DIR / "checkin.log", maxBytes=2 * 1024 * 1024, backupCount=5, encoding="utf-8" ) fh.setFormatter(fmt) sh = logging.StreamHandler() sh.setFormatter(fmt) logger.addHandler(fh) logger.addHandler(sh) return loggermaxBytes设 2MB、保留 5 个备份,也就是最多占 10MB 磁盘,对签到这种每天只写几十行的场景绰绰有余。格式里带上时间戳和级别,出问题的时候grep ERROR checkin.log一条命令就能定位到出事的时刻。
再说截图。日志能告诉你"失败了",但告诉不了你"为什么失败"。失败那一刻的页面长什么样,只有截图能回答。实现上就是driver.save_screenshot(),路径带上时间戳,和日志放在一起。
def dump_screenshot(driver, tag="fail"): ts = time.strftime("%Y%m%d_%H%M%S") path = LOG_DIR / "{}_{}.png".format(tag, ts) try: driver.save_screenshot(str(path)) return str(path) except Exception: return None实践下来,截图这个动作救过我很多次。有一次连续三天签到失败,日志里全是"未找到签到按钮",我看截图才发现页面跳到了一个活动落地页,按钮在另一个 tab 里。没有截图的话,光看日志根本想不到是这个原因。
5.2 定时任务三种落地方式的取舍
脚本写好了,接下来要解决"每天自动跑"的问题。常见方案有三种,各有适用场景,别迷信某一种。
| 方案 | 适用系统 | 优点 | 缺点 |
|---|---|---|---|
| 系统计划任务 | Win 任务计划 / Linux cron | 零依赖、开机自启、进程独立 | 配置界面不直观,环境变量容易踩坑 |
| APScheduler | 跨平台,装在脚本里 | 配置灵活、可以和主程序一起跑 | 进程挂了任务就没了,需要额外守护 |
| 手动双击 | 全平台 | 简单直接 | 需要人记得,违背自动化初衷 |
我自己的选择是:单机长期跑用系统计划任务,临时调试或需要复杂调度(比如错峰随机时间)用 APScheduler。
Windows 下的坑在于"起始于"这个字段。任务计划里如果只填了程序路径没填工作目录,脚本里的相对路径全会失效,日志写不出来、Cookie 读不到,表现就是"手动跑好好的,计划任务就是不行"。所以务必把"起始于"填成脚本所在目录,或者在代码里用Path(__file__).resolve().parent把所有路径都转成绝对路径,后者更保险,我现在一律这么写。
Linux 下用 cron 就一行事。crontab -e进去加一条:
30 8 * * * cd /home/user/checkin && /home/user/checkin/venv/bin/python main.py >> logs/cron.log 2>&1注意三点:一是必须写 venv 里 python 的绝对路径,cron 的环境变量和你登录 shell 的完全不一样,直接写python大概率找不到;二是要先cd到项目目录;三是把标准输出和错误都重定向到文件,否则出错信息会被吞掉。这三条我当年全部踩过,每一条都花了半小时以上才想明白。
用 APScheduler 的话,可以在脚本里加一个随机偏移,避开每天固定整点执行。固定整点执行的问题是多台机器同时跑的时候容易撞上服务端的高峰,而且日志时间戳一看就是机器人。加个随机延迟成本极低:
import random, time time.sleep(random.randint(0, 600))十分钟以内的随机延迟,对签到结果毫无影响,但能错开高峰。
5.3 无头模式与资源占用控制
每天跑一次的脚本,开个浏览器窗口倒也无所谓,但如果放在一台常开的机器上,或者你同时跑着别的东西,无头模式(headless)能省不少事。新版 Chrome 的无头模式用--headless=new,和老版的--headless渲染行为差异不小,老版本有些元素的定位会出问题,新版基本和正常模式一致了。
options.add_argument("--headless=new") options.add_argument("--window-size=1440,900") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage")--window-size一定要设。无头模式默认窗口大小是 800x600,很多响应式页面在这个宽度下会切成移动端布局,DOM 结构完全不一样,你按桌面端写的定位器全部失效。这个问题非常隐蔽,因为代码没错,是页面根本没按你预期渲染。
--disable-dev-shm-usage在 Linux 容器里几乎是必加项,默认的共享内存分区通常只有 64MB,Chrome 渲染复杂页面时容易把它撑爆,表现是浏览器莫名其妙崩溃,报session deleted because of page crash。加上这个参数让它改用/tmp,问题就消失了。
另外记得driver.quit()一定要放在finally里。如果脚本中途抛异常而没有关闭浏览器,chromedriver 进程会一直挂在后台,跑上一个月能攒出上百个僵尸进程,把内存吃干。这也是为什么要用try/finally而不是简单顺序执行。
6. 常见问题排查:一份按现象检索的速查表
这一章是把过去半年里真实遇到过的问题整理成清单,按"现象"归类,方便你出问题的时候直接对号入座。很多问题的报错信息和真实原因之间隔着好几层,光看报错是想不到的。
6.1 环境与启动类问题
session not created: This version of ChromeDriver only supports Chrome version XX,这是版本不匹配的明确信号,去核对浏览器和驱动的版本号,或者直接删掉手动指定的驱动路径,让 Selenium 自动管理。有些人的机器上装了多个 Chrome(测试版、正式版、绿色版),Selenium 识别的可能不是你想用的那个,这时候用options.binary_location显式指定可执行文件路径。
WebDriverException: Message: 'chromedriver' executable needs to be in PATH,说明既没找到项目目录下的驱动,也没在系统 PATH 里找到。如果你明确指定了Service(executable_path=...),那就检查路径拼写,Windows 下记得带.exe后缀,用Path拼接比手写字符串靠谱得多。
脚本在本地跑得好好的,放到服务器上启动就报DevToolsActivePort file doesn't exist,这个在 Linux 环境里非常典型,通常是缺少图形界面依赖或者/dev/shm太小。加上--no-sandbox和--disable-dev-shm-usage基本能解决,还不行就检查是不是以 root 身份跑但没有加沙箱参数。
还有一种更隐蔽的:脚本没有任何报错,就是打不开浏览器。这种情况先看杀毒软件和安全软件的拦截日志,某些安全产品会把自动化拉起浏览器的行为当作可疑动作拦掉,而且不给任何提示。
6.2 运行时行为类问题
元素明明在页面上肉眼可见,代码却报NoSuchElementException。第一反应应该是"它是不是在 iframe 里",用driver.switch_to.frame()切进去再找;第二反应是"它是不是在 shadow DOM 里",这个需要用 JS 取shadowRoot再查;第三反应是"页面还没渲染完",但既然你在用显式等待,这个可能性反而不大。我遇到过最离谱的一次是页面在主文档加载完之后才用 JS 注入整块内容,presence_of_element_located等的是 DOM 里存在,而注入的内容确实存在,只是在我查找之后才被替换掉,导致句柄失效——这种就得靠"查找加重试"的组合拳来解决。
ElementClickInterceptedException,翻译过来就是"我要点的东西被别的元素挡住了"。八成是弹窗、引导蒙层、或者固定的吸底广告条。解决办法两个:一是先关弹窗,二是在点击前用scrollIntoView把目标滚到视口中间,避开固定定位的遮挡物。实在不行用 JS 点击兜底,但要接受可能不触发真实事件的代价。
点了签到按钮,页面提示"操作过于频繁,请稍后再试"。这不是技术问题,是频率问题。检查一下是不是脚本被重复触发(计划任务配错导致一天跑好几次),或者你的账号在别的设备上也刚点过。加个随机延迟、确认执行频率,基本就能避免。
签到成功但成长值没涨。这种先别怀疑脚本,去 App 里看一眼账户明细,很多时候成长值是有延迟到账的,或者当天已经通过别的方式领过了。脚本层面能做的是把每次的页面状态截图存档,出争议的时候有据可查。
6.3 页面改版与接口变化类问题
页面改版是这类脚本的宿命,躲不掉,只能把影响面控制住。我在设计的时候有意做了几件事来降低改版冲击:所有定位器集中放在一个配置字典里,改版时只改这一处;每个关键元素都给两到三个候选定位方式,主定位失效还有备用;结果判定用关键词模糊匹配而不是精确元素;每次执行都截图,改版后第一眼就能看出变化在哪。
接口路径变化同理。如果用的是页面内 fetch 的方案,接口一改脚本就 404,这时候去抓包页面实际请求的路径,更新配置即可。如果用的是纯点击方案,接口变化反而和你没关系,这也是纯点击方案虽然慢但更抗造的原因。
还有一种情况是"登录态提前失效"。正常情况下 Cookie 能撑几周到几个月,但如果账号在别的设备上登录过、或者手动改过密码、或者平台调整了会话策略,Cookie 可能第二天就失效了。这时候人工兜底逻辑就派上用场了,脚本会停下来等你扫码,而不是默默失败。所以那个input()虽然土,但真的不能省。
| 现象 | 最可能的原因 | 处理动作 |
|---|---|---|
| 浏览器起不来 | 驱动版本不匹配 / 被安全软件拦截 | 核对版本,检查拦截日志 |
| 找不到元素 | iframe / shadow DOM / 未渲染完 | 切换上下文,加重试 |
| 点击无反应 | 被遮罩挡住 / 元素不可见 | 清弹窗,滚动到视口中央 |
| 一直显示未登录 | Cookie 未刷新 / 域名不匹配 | 加完 Cookie 后 refresh |
| 计划任务不执行 | 工作目录 / 解释器路径错误 | 全部改用绝对路径 |
| 进程越跑越多 | 异常时未 quit | try/finally 包住 |
| 页面布局错乱 | 无头模式窗口太小 | 显式设 window-size |
6.4 几个不值得踩第二次的坑
第一个:不要在代码里硬编码任何绝对路径。我最早写的版本里写着C:\Users\xxx\Desktop\cookies.json,换台机器直接崩,而且报错是"文件不存在",看起来像是逻辑问题。后来统一用Path(__file__).resolve().parent做基准,所有路径相对它拼,这个问题就再也没出现过。
第二个:不要把异常吞掉。新手常写try: ... except: pass,觉得"不报错就是成功"。恰恰相反,吞掉异常等于放弃了唯一的诊断线索。正确做法是捕获具体异常类型,记日志,关键路径上还要截图,然后决定是重试还是退出。
第三个:不要在循环里反复创建 WebDriver 实例。每次webdriver.Chrome()都要启动一个完整的浏览器进程,开销是秒级的。如果你的逻辑是"试三个定位方式,每个都新开一个浏览器",那跑一次签到能花掉一分钟。一个会话从头用到尾,中间靠刷新和重新查找来应对变化。
第四个:签到失败不要立刻重试太多次。短时间内的连续失败重试,在服务端看来就是异常流量,有可能触发限制。我的做法是失败后等 30 秒重试一次,再失败就记录日志退出,第二天再说。一天的成长值而已,不值得为了它把账号搞进风控名单。
第五个:记得定期检查日志。自动化最大的风险不是跑不起来,而是它悄悄失败了而你毫不知情。我现在每个月会翻一次日志,看看成功率,顺便确认一下 Cookie 的存活周期,心里有个数。
最后分享一个我自己一直在用的小习惯:把每次执行的"开始时间、登录态是否有效、签到结果、耗时、截图路径"这五个字段单独打到一行结构化日志里,格式固定成key=value的形式。这样时间长了之后,用一条简单的命令就能统计出这半年的成功率,也能看出 Cookie 大概多久失效一次,比翻一堆自然语言日志高效得多。这套东西最初只是为了签到写的,后来发现登录态复用、显式等待封装、失败截图这几块,换个目标页面几乎能原样搬到别的日常任务上,边际成本很低。