
1. 为什么我最后放弃裸写Selenium转而攒了一套工具集刚开始接触Selenium的那两年我的脚本基本是一个文件跑天下打开浏览器、找元素、点按钮、断言文本全塞在一个main.py里。能跑但特别脆。页面改一个id脚本就躺平换一台机器驱动版本对不上又躺平。后来我把这些反复踩的坑整理成了一套可复用的Selenium 自动化工具集核心解决三件事定位信息与代码解耦、页面元素批量枚举、操作稳定性兜底。这套东西不追求大而全它更像一个随身工具箱你写业务脚本时随手调用省掉大量重复劳动。这篇文章适合几类人看刚学selenium自动化测试框架、还在纠结selenium安装和驱动配置的新手写过一段时间脚本、被定位失效和StaleElementReferenceException折磨过的中级使用者以及想把零散脚本做成小团队可共用资产的开发者。我会从设计思路讲到具体代码包含参数选择理由、目录结构、可直接抄的配置和一份常见问题速查表。所有代码基于 Python 与 Selenium 4.x思路同样适用于 Java 或 C# 的团队只是语法要自己换。需要提前说明一点这篇内容是结合我自己的项目经验写的部分工具选型比如元数据用 YAML 还是 JSON、枚举用 JS 注入还是遍历 API是常见实践的合理补全不是唯一正确答案。你可以根据自己的团队习惯替换但背后的取舍逻辑值得看一眼因为那才是真正决定脚本活多久的东西。1.1 裸写脚本的三次真实翻车第一次翻车发生在登录页。当时我用find_element(By.ID, username)定位输入框跑了两个月都正常直到前端同事把id从username改成了login-username。脚本在预发环境直接报NoSuchElementException而我翻代码找了半天因为定位信息散落在几十个函数里根本不知道哪些地方用了这个id。那次之后我意识到一个朴素的道理定位信息属于会变的配置不应该和相对稳定的逻辑混在一起写。第二次翻车是驱动版本。本地开发机浏览器自动更新到了新版本chromedriver还是旧的脚本启动就报SessionNotCreatedException。给同事部署的时候更麻烦他机器上连驱动放哪都不知道。这个问题在 Selenium 4.6 之后其实由 Selenium Manager 大幅缓解了但当时我还没升级硬是手动管理驱动路径管理了很久。第三次翻车最典型抢一张动态渲染的页面元素在 DOM 里存在但还没渲染可见我直接click()结果点到的是覆盖层报ElementClickInterceptedException。加time.sleep(3)能糊过去但那是赌博网络快的时候白等三秒网络慢的时候三秒也不够。这就是为什么工具集里必须有一套统一的等待与重试机制而不是让每个开发者凭感觉加sleep。1.2 工具集的能力边界它做什么不做什么先把边界说清楚免得期待错位。这套工具集主要覆盖四块能力定位元数据管理把by和value从代码里抽出来集中存放、统一解析。这就是热词里说的仅存储定位元数据——元数据文件里只放怎么找到元素不放找到之后干什么。页面元素枚举给定一个页面地址自动扫描出页面上所有可交互元素输出成结构化清单。热词里的selenium 页面元素枚举说的就是这个场景特别适合接手一个陌生页面、或者页面刚改版时快速摸底。等待与重试封装统一显式等待、统一重试策略、统一异常处理避免sleep满天飞。执行留证失败自动截图、记录当前 URL 和页面标题方便事后排查。它不做的事情也要讲明白不做测试用例管理那是 pytest、TestNG 的活不做分布式执行调度那是 Selenium Grid 或云平台的活不做视觉比对那是图像比对工具的活。把它当成写脚本时的脚手架和胶水层定位就对了。你要是指望它替你把整个测试体系搭起来那会失望。1.3 这套东西适合谁不适合谁适合的人已经能写出能跑的 Selenium 脚本但脚本一改就崩、一部署就挂想找个工程化落点的开发者需要多人协作维护脚本、希望定位信息统一管理的团队经常要摸陌生页面、想快速拿到元素清单的人。不太适合的人完全没写过 Selenium、连driver.get()都没跑通过的新手——建议先把官方文档里的入门例子完整跑一遍理解 WebDriver 的基本模型再来看工具集不然会觉得为什么要搞这么复杂。另外如果你只是临时抓一次数据、脚本用完就扔那确实没必要上这套结构直接写十来行代码更划算。工具集的价值在于重复使用一次性任务用不上。2. 环境搭建把Selenium从装到跑通这一步走稳环境问题是新手卡住最多的地方而且报错信息往往不直观。我见过太多人卡在驱动和浏览器版本不匹配上然后去搜各种偏方装了一堆来路不明的驱动包。这一节我按顺序讲清楚装什么、怎么装、目录怎么放、怎么验证。跟着走一遍基本能避开九成环境坑。2.1 Python环境与包安装的正确姿势先说 Python 版本。Selenium 4.x 要求 Python 3.7 以上我目前主力用 3.10 或 3.11兼容性最省心。强烈建议用虚拟环境别往系统 Python 里瞎装包否则以后多个项目版本冲突会让你头疼。虚拟环境怎么建取决于你的工具链用venv、conda或者项目自带的poetry都行选一个你顺手的。# 用 venv 建一个干净环境 python -m venv .venv # Linux / macOS 激活 source .venv/bin/activate # Windows 激活 .venv\Scripts\activate激活之后装 Selenium。如果网络下载慢可以换用国内镜像源加速这属于常规操作不涉及什么特殊手段pip install selenium # 网络慢的话换源 pip install selenium -i https://pypi.tuna.tsinghua.edu.cn/simple装完之后建议顺手装上pyyaml元数据用 YAML 存的话和pytest后续组织用例用pip install pyyaml pytest有个小习惯我建议你养成把依赖固定到requirements.txt里用pip freeze requirements.txt导出。为什么因为三个月后你换个机器复现环境时selenium4.18.1和selenium最新版可能是两个世界版本漂移是脚本本地能跑、线上报错的常见原因。提示不要用pip install selenium --upgrade无脑升级。升级前先确认现有脚本的兼容性Selenium 4.x 内部各小版本也有行为差异贸然升级会引入意外问题。2.2 浏览器与驱动版本对齐这件事这是老生常谈但必须讲。Selenium 的工作模式是你的代码 - WebDriver 客户端 - 浏览器驱动 - 浏览器。驱动和浏览器大版本必须匹配。以前要手动下载chromedriver放进 PATH现在 Selenium 4.6 之后内置了Selenium Manager大多数情况下你什么都不用管webdriver.Chrome()会自动帮你找到并下载匹配的驱动。from selenium import webdriver # Selenium Manager 会自动处理驱动 driver webdriver.Chrome() driver.get(https://example.com) print(driver.title) driver.quit()如果你所在的环境有网络代理限制、或者需要用特定版本的驱动那还是得手动指定from selenium import webdriver from selenium.webdriver.chrome.service import Service service Service(executable_path/path/to/chromedriver) driver webdriver.Chrome(serviceservice)我的经验是内网环境、离线环境、需要精确锁定驱动版本做兼容测试的场景手动指定更可控日常开发直接用自动管理省事。另外无头模式和普通模式在个别站点上行为不同工具集里最好把启动参数抽成配置方便切换。from selenium import webdriver from selenium.webdriver.chrome.options import Options def build_driver(headlessTrue, window_size1440,900): options Options() if headless: options.add_argument(--headlessnew) options.add_argument(f--window-size{window_size}) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_experimental_option(excludeSwitches, [enable-logging]) return webdriver.Chrome(optionsoptions)--no-sandbox和--disable-dev-shm-usage这两个参数在容器里跑的时候经常是救命的能避免共享内存不足导致的崩溃。但要注意--no-sandbox降低了一定隔离性生产环境用不用要看你的安全要求本地和 CI 里用问题不大。2.3 项目目录结构设计目录结构看着是小事实际上决定了工具集好不好用、能不能被多人接手。我目前用的结构是这样的供你参考selenium-toolkit/ ├── toolkit/ │ ├── __init__.py │ ├── driver.py # 驱动构建与销毁 │ ├── locator.py # 定位元数据解析 │ ├── waiter.py # 等待封装 │ ├── retry.py # 重试装饰器 │ ├── enums.py # 元素枚举 │ └── report.py # 截图与留证 ├── locators/ │ ├── login.yaml # 页面对应的定位元数据 │ └── home.yaml ├── js/ │ └── enumerate.js # 注入用的枚举脚本 ├── tests/ │ └── test_smoke.py ├── requirements.txt └── README.md关键点在于toolkit和locators分开前者是稳定的代码逻辑后者是易变的配置数据。这个分界一旦立住后续维护的心智负担会小很多。你可以想象一下页面改版时你只需要改locators/*.yaml而不用碰toolkit里任何一行逻辑代码——这就是解耦带来的实际收益。2.4 冒烟脚本五行代码验证环境环境搭完别急着写业务先跑个最小冒烟脚本确认链路是通的。我习惯用example.com这种稳定页面做验证避免拿业务站点测出问题分不清是环境问题还是站点问题。from toolkit.driver import build_driver driver build_driver(headlessTrue) driver.get(https://example.com) assert Example in driver.title, 标题不符合预期环境可能有问题 print(冒烟通过标题:, driver.title) driver.quit()跑通这一步说明 Python、Selenium、驱动、浏览器四者都对上了。如果这里就报错先别往下走把错误信息贴出来逐条排查SessionNotCreatedException多半是驱动版本WebDriverException可能是浏览器路径或权限问题TimeoutException通常是网络或页面加载问题。把错误分类清楚排查效率能提高一大截。3. 定位元数据管理让元素定位与代码彻底解耦这一节是工具集的核心价值所在。前面说过仅存储定位元数据的意思是元数据文件里只回答这个元素怎么找不回答找到它之后点它还是填它。业务动作留在代码里定位信息留在配置里两者通过一个键名关联。这么设计之后页面改版只需要动配置逻辑代码零改动同时定位信息集中一眼就能看出哪些页面依赖哪些元素。3.1 为什么仅存储定位元数据是个好主意先说个反面例子。如果定位信息散在代码里你可能在一个项目里看到这样的局面login功能用#usernameprofile功能用//input[nameuser]明明是同一个输入框写法两种。改版的时候你要么漏改要么改错。更麻烦的是新同事接手他得把代码全读一遍才知道哪些元素被用了、用在哪。改成元数据集中管理之后好处立刻显现一是改版成本低定位变了只改一处二是可读性强打开login.yaml就知道登录页涉及哪些元素三是便于工具化因为结构统一后面做元素枚举、自动生成草案、定位健康检查都变得顺理成章。热词里反复出现的页面元素枚举和仅存储定位元数据本质上是同一个思路的两面先摸清页面上有什么再把怎么找记录下来最后才是做什么。3.2 元数据文件的结构设计我用 YAML因为它支持注释、可读性好。如果你团队更喜欢 JSON完全可以替换解析层改几行就行。一份登录页的元数据大概长这样login_page: username_input: by: css value: #username desc: 用户名输入框 wait: visible password_input: by: css value: #password desc: 密码输入框 wait: visible submit_button: by: xpath value: //button[typesubmit] desc: 登录按钮 wait: clickable error_tip: by: css value: .error-msg desc: 错误提示 wait: present四个字段by是定位方式value是定位值desc是给人看的描述排查时非常有用wait是等待策略。注意最后这个wait字段——它把用哪种等待也变成了配置而不是写死在代码里。为什么因为同一个元素在不同页面状态下需要的等待类型不一样静态文本用present就够了可点击按钮得用clickable需要读取文本的得用visible。把它放进配置调整时不用动逻辑。字段设计上有两个小经验值得分享。第一desc千万别省它是你半年后翻配置时唯一的救命稻草第二value用单引号包起来避免 YAML 里:和#被误解析这个坑我踩过报错信息还很迷惑。3.3 定位解析引擎的实现元数据是死的得有个解析引擎把它变成 Selenium 能用的By对象。这部分代码不长但设计要严谨因为它被所有业务脚本依赖。from selenium.webdriver.common.by import By BY_MAP { id: By.ID, name: By.NAME, css: By.CSS_SELECTOR, xpath: By.XPATH, class: By.CLASS_NAME, tag: By.TAG_NAME, link: By.LINK_TEXT, plink: By.PARTIAL_LINK_TEXT, } class Locator: def __init__(self, by, value, desc, waitNone): if by.lower() not in BY_MAP: raise ValueError(f不支持的定位方式: {by}) self.by BY_MAP[by.lower()] self.value value self.desc desc self.wait wait property def pair(self): return (self.by, self.value) def __repr__(self): return fLocator {self.by}{self.value} desc{self.desc}再写一个加载器把整个 YAML 读进来转成Locator对象字典import yaml def load_locators(path): with open(path, encodingutf-8) as f: raw yaml.safe_load(f) result {} for page_name, elements in raw.items(): result[page_name] { key: Locator( bycfg[by], valuecfg[value], desccfg.get(desc, ), waitcfg.get(wait) ) for key, cfg in elements.items() } return result调用的时候就很干净了locators load_locators(locators/login.yaml) login locators[login_page] driver.find_element(*login[username_input].pair).send_keys(demo_user)对比一下裸写find_element(By.CSS_SELECTOR, #username)这里的可维护性提升是实打实的。当然一开始你可能觉得配置和代码要来回跳有点烦但等到页面改版一次你就知道值了。注意BY_MAP一定要在解析时做校验。我见过因为配置里by写错一个字母报错信息指向 Selenium 内部排查半天才发现是配置笔误。提前抛明确的ValueError能省下很多时间。3.4 页面对象与元数据的协作方式元数据解决怎么找页面对象Page Object解决做什么。两者结合的方式很简单页面对象初始化时加载对应元数据方法里通过键名取定位器。class LoginPage: def __init__(self, driver, waiter, locators): self.driver driver self.waiter waiter self.loc locators[login_page] def login(self, username, password): self.waiter.visible(self.loc[username_input]).send_keys(username) self.waiter.visible(self.loc[password_input]).send_keys(password) self.waiter.clickable(self.loc[submit_button]).click() def error_message(self): return self.waiter.present(self.loc[error_tip]).text这里的waiter是下一节要讲的等待封装。整个链路是元数据 - 定位器 - 等待 - 操作。每一层职责单一出了问题定位到对应层就行。这种分层乍看啰嗦但当一个项目有几十个页面、上百个元素时它是唯一能让你保持清醒的组织方式。4. 页面元素枚举把页面上能点的东西一次摸清如果你接手过一个完全陌生的页面或者页面刚大规模改版你会发现一个个找元素效率极低。selenium 页面元素枚举的思路是写一段脚本自动扫描页面上所有可能可交互的元素链接、按钮、输入框、下拉框、带 role 或 onclick 的元素输出成清单。拿这份清单你既能快速了解页面结构也能反推出一份元数据草案省掉大量手工复制粘贴。4.1 元素枚举到底解决什么问题先说清楚它的价值免得你觉得不就是遍历 DOM 吗。三个典型场景第一陌生页面摸底拿到清单就知道页面有哪些交互入口、大概怎么组织第二改版影响评估对比改版前后的清单快速看出哪些元素没了、哪些新增了第三元数据草案生成把枚举结果按规则转成 YAML人工再微调比重头写快得多。它也有局限枚举只能拿到静态 DOM 里存在的元素动态加载、懒加载、需要滚动才出现的元素得配合滚动和等待策略另外枚举出的定位值比如#id可能不稳定尤其是前端用动态id的页面这类元素要么让前端加稳定属性要么改用相对定位。所以枚举结果是起点不是终点。4.2 用注入式脚本扫描DOM实现方式有两种一是用 Selenium 的find_elements逐个标签遍历二是注入 JavaScript 直接在页面里扫描。我更推荐第二种原因是快且能拿到更多属性。JS 在页面上下文里跑一次querySelectorAll就能拿到全部结果再通过execute_script返回给 Python。const selector a, button, input, select, textarea, [role], [onclick]; const nodes document.querySelectorAll(selector); const result []; nodes.forEach((el, idx) { const rect el.getBoundingClientRect(); result.push({ index: idx, tag: el.tagName.toLowerCase(), id: el.id || null, name: el.getAttribute(name), cls: typeof el.className string ? el.className : null, text: (el.innerText || el.value || ).trim().slice(0, 40), type: el.getAttribute(type), role: el.getAttribute(role), placeholder: el.getAttribute(placeholder), visible: rect.width 0 rect.height 0 }); }); return result;这里特意加了visible字段用getBoundingClientRect()判断元素是否真的占据空间。为什么加这个因为很多页面会藏一堆display:none的元素比如弹窗模板、隐藏表单枚举出来的清单如果不过滤会非常嘈杂。把可见性带出来后面过滤就方便了。Python 侧调用def enumerate_elements(driver, urlNone, js_pathjs/enumerate.js): if url: driver.get(url) with open(js_path, encodingutf-8) as f: script f.read() return driver.execute_script(script)4.3 枚举结果的结构化输出拿到结果之后落盘成 JSON 和 CSV 两份。JSON 保留完整结构方便后续程序处理CSV 给人工看用表格软件打开一目了然。import json import csv FIELDS [index, tag, id, name, cls, text, type, role, placeholder, visible] def dump_elements(elements, json_pathout/elements.json, csv_pathout/elements.csv): with open(json_path, w, encodingutf-8) as f: json.dump(elements, f, ensure_asciiFalse, indent2) with open(csv_path, w, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnamesFIELDS) writer.writeheader() for e in elements: writer.writerow({k: e.get(k) for k in FIELDS}) print(f已导出 {len(elements)} 个元素)实测下来一个普通登录页大约能枚举出 15 到 30 个候选元素一个中等复杂度的后台页面能到 100 以上。清单铺开之后你会发现以前靠肉眼找、靠 DevTools 一个个点的方式效率差得不是一点半点。我做过对比摸一个陌生页面手工找元素大概要半小时枚举加筛选十分钟内能搞定而且不容易漏。4.4 从枚举清单反推元数据草案光有清单还不够最好能自动生成一份元数据草案把重复劳动再砍一刀。生成规则很简单优先用id最稳定其次用name再不行用标签加序号兜底同时把desc填成元素文本方便人工核对。def to_locator_draft(elements, page_name): lines [f{page_name}:] used set() for e in elements: if not e.get(visible): continue if e.get(id): by, value css, f#{e[id]} key_base e[id] elif e.get(name): by, value css, f[name{e[name]}] key_base e[name] else: by, value xpath, f//{e[tag]}[{e[index] 1}] key_base f{e[tag]}_{e[index]} key key_base.replace(-, _).replace(., _) if key in used: key f{key}_{e[index]} used.add(key) desc (e.get(text) or e.get(placeholder) or e[tag]).strip() lines.append(f {key}:) lines.append(f by: {by}) lines.append(f value: {value}) lines.append(f desc: {desc}) return \n.join(lines)这份草案不要直接用一定要人工过一遍。原因在于自动生成的定位值未必稳定尤其xpath兜底出来的那种第几个 div页面稍微一动就失效。我的做法是自动生成放到七成剩下三成手工调成更稳定的定位方式比如给关键元素加>from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class Waiter: def __init__(self, driver, timeout10, poll0.3): self.driver driver self.timeout timeout self.poll poll def _wait(self, timeoutNone): return WebDriverWait(self.driver, timeout or self.timeout, self.poll) def present(self, locator, timeoutNone): return self._wait(timeout).until( EC.presence_of_element_located(locator.pair)) def visible(self, locator, timeoutNone): return self._wait(timeout).until( EC.visibility_of_element_located(locator.pair)) def clickable(self, locator, timeoutNone): return self._wait(timeout).until( EC.element_to_be_clickable(locator.pair)) def texts(self, locator, timeoutNone): return self._wait(timeout).until( EC.presence_of_all_elements_located(locator.pair))关于超时时间怎么定我说下自己的经验值本地开发环境 10 秒够用CI 环境因为机器性能波动大建议放宽到 15 到 20 秒。轮询间隔用 0.3 到 0.5 秒太小了频繁查询浪费资源太大了响应迟钝。这些值别写死放进配置不同环境用不同参数这是被生产环境教育出来的习惯。四种等待类型的选用也有讲究present只要求元素在 DOM 里最快适合只要拿文本的场景visible要求元素可见适合读取、输入clickable要求元素可见且可交互适合点击texts是复数版本用于列表。选对类型能显著减少等待耗时别一律用clickable图省事那是拿时间换省心。5.2 重试装饰器的设计有些异常是偶发的StaleElementReferenceException元素句柄过期通常是 DOM 被重新渲染了、ElementClickInterceptedException点击被遮挡常见于动画或浮层。这类异常重试一两次往往就好了。用一个装饰器统一处理比在每个方法里写 try-except 干净得多。import time import functools from selenium.common.exceptions import ( StaleElementReferenceException, ElementClickInterceptedException, ) RETRY_EXCEPTIONS ( StaleElementReferenceException, ElementClickInterceptedException, ) def retry(times3, base_delay0.5, exceptionsRETRY_EXCEPTIONS): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): last_error None for attempt in range(times): try: return func(*args, **kwargs) except exceptions as e: last_error e delay base_delay * (attempt 1) print(f第 {attempt 1} 次重试等待 {delay}s{type(e).__name__}) time.sleep(delay) raise last_error return wrapper return decorator这里用了递增延迟base_delay * (attempt 1)而不是固定延迟。为什么因为偶发异常通常是短暂状态第一次重试等待短一点如果还失败说明问题可能更复杂给更多时间让页面缓过来。这比固定间隔的鲁棒性更好。用起来是这样class ElementActions: def __init__(self, driver, waiter): self.driver driver self.waiter waiter retry(times3) def click(self, locator): self.waiter.clickable(locator).click() retry(times3) def type_text(self, locator, text, clearTrue): el self.waiter.visible(locator) if clear: el.clear() el.send_keys(text) return el注意重试要克制。只对确定是偶发的异常重试。像NoSuchElementException这种说明元素压根不存在重试三次只是浪费三份时间。把可控的等待交给Waiter把偶发兜底交给retry职责别混。另外重试次数别设太高一般 3 次够了设到 10 次只会让失败反馈变慢。5.3 异常分类、截图与日志留证脚本失败时最有价值的不是错误堆栈而是失败那一刻页面长什么样。所以工具集里必须有一个失败留证的机制捕获异常、截图、记录 URL 和标题最好再带上时间戳。import os import time import traceback class FailureRecorder: def __init__(self, driver, out_dirout/failures): self.driver driver self.out_dir out_dir os.makedirs(out_dir, exist_okTrue) def capture(self, tagerror): ts time.strftime(%Y%m%d_%H%M%S) name f{tag}_{ts} png os.path.join(self.out_dir, f{name}.png) txt os.path.join(self.out_dir, f{name}.txt) try: self.driver.save_screenshot(png) info { url: self.driver.current_url, title: self.driver.title, traceback: traceback.format_exc(), } with open(txt, w, encodingutf-8) as f: for k, v in info.items(): f.write(f {k} \n{v}\n\n) except Exception as e: print(f留证失败{e}) return png把这段和异常处理结合起来业务代码就变成这样def safe_run(driver, recorder, action, tagrun): try: return action() except Exception: recorder.capture(tag) raise异常分类这块我按处理策略分成三档做一张表方便你对照异常类型典型原因处理策略TimeoutException元素等待超时检查定位、检查网络、适当加超时NoSuchElementException元素不存在改定位不要重试StaleElementReferenceExceptionDOM 被重渲染重新定位后重试ElementClickInterceptedException被浮层遮挡等遮挡消失或滚动到元素ElementNotInteractableException元素不可交互检查是否隐藏、是否需先触发SessionNotCreatedException驱动与浏览器不匹配检查驱动版本InvalidSelectorException定位语法错误检查 xpath/css 写法这张表我贴在项目 README 里新同事接手时先看它能挡掉一大半重复提问。6. 常见问题速查与踩坑实录这一节是我这些年攒下来的问题集合基本都是文档里不会写、但实际一定会遇到的。我按类别整理方便你出问题时直接对照。每条都尽量给出现象 原因 解决而不是只给结论因为知道原因才能举一反三。6.1 环境与版本类问脚本本地能跑换台机器就报驱动错误怎么办先确认三件事浏览器大版本、Selenium 版本、是否启用了 Selenium Manager。最省事的办法是把 Selenium 升到 4.6 以上让它自动管理驱动如果必须手动管就固定驱动版本把驱动路径写进配置别依赖系统 PATH。问无头模式下页面元素找不到普通模式却正常。这是很典型的问题。无头模式默认视口尺寸和普通模式不同某些响应式页面会渲染出不同的布局导致定位失效。解决办法是显式设置窗口尺寸比如--window-size1440,900尽量和真实环境对齐。另外无头模式对某些动画、字体加载的处理也有差异遇到诡异问题可以先用普通模式验证逻辑再切回无头。问容器里跑 Selenium 频繁崩溃。八成是共享内存不足。加上--disable-dev-shm-usage让浏览器把临时文件写到磁盘而不是/dev/shm再加--no-sandboxCI 环境通常能解决。如果还崩检查容器内存是不是给太小了浏览器本身也挺吃内存的。6.2 定位失效类问元素明明在页面上就是定位不到。按这个顺序排查一元素是不是在 iframe 里如果是要先switch_to.frame()二是不是在 Shadow DOM 里普通定位穿不过去得用shadow_root三是不是还没加载出来用visible等待试试四定位值本身是不是写错了打开 DevTools 用同样选择器搜一下。这四步走完基本能定位到原因。问id是动态生成的比如idinput-3829怎么办这种页面别用id。优先级从高到低建议>el waiter.visible(locator) driver.execute_script( arguments[0].value arguments[1]; arguments[0].dispatchEvent(new Event(input));, el, 中文内容 )注意 JS 设值不会触发完整的框架事件某些依赖 React/Vue 双向绑定的页面可能识别不到所以要配合dispatchEvent手动触发。问脚本跑着跑着浏览器自己关了。检查是不是哪里调用了driver.quit()却没处理异常或者浏览器进程被系统回收内存不足。调试时可以在关键节点加日志确认退出发生在哪一步。另外用driver.close()只关当前窗口driver.quit()会关整个会话两者别搞混。6.4 一些踩过的坑和实操心得分享几个一般文档不会提但我觉得挺重要的点。第一个是关于截图时机。很多人失败时才截图但那时候页面可能已经跳转或刷新了截到的不是出错现场。我的做法是在关键操作前也留一张图尤其是跳转类操作形成操作前 失败后的对照组排查时信息量翻倍。第二个是关于execute_script的返回值。注入 JS 枚举元素时返回的必须是可以序列化的对象。我早期犯过错直接在 JS 里返回 DOM 节点结果 Python 侧收到的是空值或者报错。记住JS 侧一定要把节点转成普通对象{tagName, id, ...}再返回别直接return el。第三个是关于元数据的粒度。太细会把每个元素都塞进去维护量大太粗又起不到解耦作用。我的经验是按页面 功能区划分比如login.yaml、order_list.yaml单个文件控制在 30 个元素以内超过就说明这个页面该拆了。第四个是关于等待的层级。我不建议在元数据里把超时时间也写死因为同一个元素在不同场景下需要的等待时长不同。元数据里只放等什么类型wait: clickable等多久交给Waiter的全局配置或调用时传入这样更灵活。第五个是排查顺序的固定化。遇到脚本失败我固定按环境 - 定位 - 等待 - 业务逻辑的顺序排查从底层往上找。这个顺序是从无数次踩坑里总结的能避免一上来就怀疑业务代码结果发现其实是驱动没配好。最后说个关于元素枚举的心得。枚举出来的清单一定要结合时间维度看也就是至少跑两次对比差异。为什么因为动态页面每次加载的元素顺序、数量都可能不同只跑一次可能把偶发出现的元素当成稳定存在的。跑两三次取并集再人工筛出真正稳定的那些用于生成元数据质量会高很多。另外枚举时把每个元素的可见性、位置信息也带上后续判断哪些是主要交互入口会更容易——位置在可视区内的通常是主流程元素藏在角落的多半是辅助功能。这套工具集我用了大概两年期间迭代了好几版。最大的体会是自动化脚本的稳定性不是靠某一行代码写得多聪明而是靠整体结构把不确定性一层层隔离掉。驱动问题隔离在环境层定位问题隔离在元数据层等待问题隔离在 Waiter 层偶发问题隔离在重试层剩下的才是业务逻辑。每层都简单组合起来就稳。你要是刚开始搭别一次全上先从元数据管理和等待封装这两块入手立竿见影元素枚举和定位体检可以第二步再补。跑起来之后再回头调整结构比一开始就设计完美方案要实际得多。