ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Selenium execute_script实战:复杂页面自动化的高频JS脚本详解

Selenium execute_script实战:复杂页面自动化的高频JS脚本详解

做Web自动化有三四年了,早期用Selenium基本就是find_element、click、send_keys三板斧走天下。后来真正上手复杂页面,比如滚动加载的商品列表、被fixed导航栏遮住保存按钮的后台系统、用React/Vue动态渲染的表单,原生API就开始频繁卡壳。第一次接触driver.execute_script()的时候,我把一段JS先在浏览器控制台验证好,再塞进自动化脚本,那种“原来还能这样”的感觉特别强烈。这篇文章想把我在真实项目中沉淀下来的execute_script高频JS脚本整理成文,包括滚动控制、隐藏元素操作、数据提取、前端框架事件触发、性能统计,还有一套滚动加载页面的完整采集实战。适合已经会基本Selenium操作、但遇到复杂页面不知道怎么下手的开发者;纯新手的话,建议先掌握基础定位和单击操作再来看这篇。

1. 为什么学会了execute_script,Selenium才算真正毕业

1.1 原生Selenium API搞不定的四类典型场景

多数人学Selenium,先接触的是WebDriver自带的高层API,比如click、send_keys、scroll_into_view。它们的问题在于:封装在“用户级操作”这个层面,一旦遇到浏览器内部状态或者DOM边缘情况,就会用力过猛又使不上劲。我实际项目里遇到的四类场景最有代表性。

第一类是元素“看得见却点不到”。明明用find_element找到了按钮,元素也在页面里,点击却一直报element not interactable。根因通常是按钮上方有一个fixed定位的导航栏、一个加载中的半透明遮罩,或者层级更高的弹窗。原生click()按元素中心坐标去点击,实际命中却被更高层元素拦截。这种问题靠加等待、滚动都没法根治,但一行JS就能解决。

第二类是原生滚动不够灵活。execute_script能做到浏览器级的滚动,比如直接滚到document底部、把某个元素滚到视口正中、精确控制任意滚动位置。原生Selenium虽然也有Action类滚动方法,但在iframe、双滚动容器、懒加载页面里,操作粒度往往不够细。

第三类是隐藏元素和禁用元素的操作。很多后台系统在编辑态会把输入框设置为display:none或者readonly,原生send_keys直接忽略这些元素。通过JS修改样式、移除属性则非常直接,相当于强行让这个元素变成可操作状态。

第四类是批量拿数据。原生API要拿单个元素的文本、属性没问题,但批量拿一整页商品卡片的标题、价格、链接并做复杂过滤时,原生写法又长又绕。一段JS在浏览器上下文里写一个map操作,一次execute_script就能返回整个数组,代码更短,效率也高。

1.2 execute_script的底层逻辑:你其实是在浏览器控制台敲代码

理解execute_script的关键,是把它想象成在浏览器控制台手动敲入并执行这段JS。WebDriver把脚本字符串交给浏览器引擎执行,执行结果通过JSON返回给自动化代码。它和手动控制台操作的区别只有三点:可以通过arguments[n]接收外部传参、可以用return把值传回自动化代码、执行上下文的window就是当前页面窗口。

这意味着,你在浏览器Console里能完成的事情,理论上都能在Selenium里做。所以我的习惯是:先把脚本在Console里手工调试一遍,确认语法和兼容性没问题,再贴到Python里封装成函数。这样能快速验证,避免把未经测试的脚本堆进自动化流程,出问题后无从排查。

不过execute_script不是万能钥匙。比如文件上传场景里,真实文件路径无法通过JS伪造,浏览器安全机制不允许脚本直接给file input设置本地路径。再比如验证码相关操作,用JS去绕过既不稳定也不合规。理解它的边界,才能真正安全地使用它。

2. 调用前的关键准备:execute_script的语法与参数传递

2.1 三种调用姿势:无参、传元素、传字符串

execute_script(script, *args) 第一个参数是JS代码字符串,后面的参数会依次映射为JS里的arguments[0]、arguments[1]……。最简单的无参调用,比如滚动:

driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")

这条执行完后没有返回内容,Python端拿到的就是None。

第二种常见姿势是传WebElement进去。比如点击被遮挡按钮时:

btn = driver.find_element(By.ID, "submit") driver.execute_script("arguments[0].click();", btn)

这里要注意一个非常有用的特性:传入的Selenium WebElement对象,在JS里拿到的是一个DOM节点引用,可以直接调用click()、setAttribute等原生DOM方法。反过来也一样,如果JS里return document.querySelector(...),Python端收到的是一个WebElement对象,可以继续用原生API操作它。

第三种是传字符串、数字、字典等普通数据:

driver.execute_script("arguments[0].setAttribute('data-id', arguments[1]);", el, 10086)

用arguments[n]传参,比自己拼接变量进JS字符串安全得多。我见过有人直接把变量拼到脚本里,结果变量中包含引号或反斜杠时,整个脚本直接报错。传参方式不需要考虑转义问题,可维护性也更好。尤其在脚本比较长的时候,字符串拼接会让代码可读性很差。

2.2 返回值怎么拿:从JS到Python的数据转换规则

execute_script的返回值序列化规则比较直观。数字、字符串、布尔值会对应转换成整数、字符串、布尔值;数组转成Python list;普通对象转成Python dict。只有DOM节点会被识别成WebElement对象,函数、Date对象、Map这类非JSON类型可能会丢失或变成其他形式。

所以我的经验是:需要返回的数据,尽量整理成纯JSON结构,避免直接返回复杂对象。比如采集一组链接,可以在JS里用map把它转换成对象数组:

links = driver.execute_script(""" return Array.from(document.querySelectorAll('a')).slice(0, 10).map(function(a){ return {text: a.innerText, href: a.href}; }); """)

这样得到的links就是一个list[dict],非常适合后续断言、写CSV或者入库。如果你需要清洗文本里的多余空格,直接在JS里用trim()、replace(/\s+/g, ' ')处理,比回到Python再洗要省事得多。

2.3 async脚本什么时候用:execute_async_script与execute_script的分工

execute_script是同步执行的,它会等脚本跑完并拿到返回值才继续。但有些场景需要处理异步操作,比如等待某个回调、资源加载或者JS计算完成。Selenium提供了execute_async_script,核心区别是脚本不会自动结束,而是通过一个回调函数通知WebDriver结果,这个回调会自动作为arguments的最后一个参数传入。

简单示例:

result = driver.execute_async_script(""" var done = arguments[0]; window.setTimeout(function(){ done(12345); }, 2000); """)

这段代码会在约2秒后返回12345。虽然实际项目中,等待元素出现更多还是用WebDriverWait,但当你要等的是一个JS层面的回调结果,或者某个动画结束之后再继续,execute_async_script就能减少轮询成本,让脚本逻辑更清爽。

需要注意,如果你在execute_script里写了setTimeout做异步任务,同步版本的函数不会等你,会直接继续往下执行。只有async版本才会让WebDriver等回调触发。我在项目里大部分场景用同步版本,因为大多数DOM操作都是同步的;只有专门等待某些异步计算结果时,才切到async版本。

3. 高频脚本一:滚动、显隐控制与点击穿透

3.1 页面滚动的一组实用写法

滚动操作是我使用频率最高的JS脚本类型。几个最常用的写法先列出来:

// 滚动到底部 window.scrollTo(0, document.body.scrollHeight); // 滚动到顶部 window.scrollTo(0, 0); // 滚动到指定元素并垂直居中 arguments[0].scrollIntoView({block: 'center'}); // 获取当前滚动位置 return window.pageYOffset || document.documentElement.scrollTop;

实际项目里我最常用的是scrollIntoView({block: 'center'})。原生Selenium也提供scroll_into_view,但它只是把元素滚动到视口边缘;如果元素正好处于视口最底部,点击时依然可能被底部工具栏遮挡。用JS把元素滚动到视口中央,点击成功率会明显提高。block:'center'是垂直居中,inline:'center'是水平居中,两者可以组合使用。

对于滚动加载页面,还有一个经典的高度判断法。先记录当前document.documentElement.scrollHeight,滚动到底部后再取一次,两次相等就说明没有新内容加载。这个方法配合循环使用,几乎是无限滚动页面的标准解法,具体实战在第六部分展开。

3.2 让隐藏/禁用元素“出来干活”的脚本

有时候表单里存在隐藏字段,本身是可选填项,但自动化测试需要直接赋值。常见做法是通过JS把display设为block、把visibility设为visible,或者移除readonly/disabled属性:

driver.execute_script("arguments[0].style.display='block';", el) driver.execute_script("arguments[0].removeAttribute('readonly');", el) driver.execute_script("arguments[0].removeAttribute('disabled');", el)

这里有个容易被忽略的细节:直接修改单个元素的style.display,可能仍然被祖先元素的隐藏属性影响。如果整个父容器处于display:none,改子元素是没用的,需要逐级往上检查并修改。更稳妥的做法是先检查元素及其所有父节点的计算样式,找出真正隐藏的那一层,再针对它处理。

另一个建议是,操作完隐藏状态后要做一次“现场还原”。尤其是断言开始之前,元素本应保持隐藏,结果被改成显示,最后截图里出现不该有的内容,排查起来会很迷惑。我习惯先把原始值存到变量里,用完再恢复,这样页面状态始终和真实用户视角一致。

3.3 点击被遮挡元素的终极方案

最初遇到点击遮挡,我的第一反应是先把页面往下滚,滚了半天发现还是不行,最后才想到用JS直接调用click()。JS的写法很简单:

driver.execute_script("arguments[0].click();", btn)

它和Selenium原生click()最大的区别在于事件触发路径不一样。原生click会模拟完整的用户操作,包括鼠标移动到元素中心、按下、抬起,过程中会触发鼠标事件和hover状态,也正因为如此才会被遮挡层干扰。而JS click是DOM标准合成事件,不计算坐标、不做可点击性检查,直接派发一个点击事件。

好处是稳定,坏处是缺少了部分真实鼠标交互效果。比如,一个只有点击按钮才展开的下拉菜单,JS click通常也能触发,因为click事件本身就够用;但如果按钮依赖mousedown事件去展开,JS click可能就触发不了。所以我的原则是:能用原生click优先用原生,只有被遮挡或非要害交互时才用JS click兜底。

还有一种方式是elem.dispatchEvent(new MouseEvent('click', {bubbles: true})),和直接click()的区别在于可以自定义事件选项。实测下来,大多数场景直接click()已经足够,dispatchEvent留作特殊情况备用。

4. 高频脚本二:属性读写、数据提取与前端框架兼容

4.1 批量采集页面数据的几行JS

数据采集场景中,execute_script的价值体现得最明显。比如要采集一个商品列表页的标题、价格和链接:

data = driver.execute_script(""" return Array.from(document.querySelectorAll('.product-item')).map(function(item){ return { title: item.querySelector('.title').innerText.trim(), price: item.querySelector('.price').innerText.trim(), url: item.querySelector('a').href }; }); """)

这里有一个新手最容易踩的坑:document.querySelectorAll返回的是NodeList,不是真正的数组,直接调用.map会报错。必须先Array.from()转换,或者用Array.prototype.slice.call()处理。这个坑我在早期踩过好几次,后来已经条件反射了。

对于更复杂的数据清洗,也可以在JS里定义辅助函数。比如把时间戳转成可读字符串,把价格字符串里的货币符号去掉,把缩略地址补全成完整URL。这些操作在JS里做,代码不多,还省得在Python里再做一轮循环。最后返回纯JSON结构,数据质量高且处理速度快。

4.2 修改输入框并触发React/Vue事件

这是execute_script使用中“最容易踩坑”的场景,没有之一。当你给一个React受控组件输入框设置value时,如果只改元素属性,React内部并不知道数据变了。React的受控组件会监听value变化来同步状态,单纯用el.value = 'xxx'赋值,界面上可能看起来还是旧值,提交时后端拿到的也不会是新值。

正确的做法是,用原生value setter设置值,再触发input事件:

driver.execute_script(""" var input = arguments[0]; var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, arguments[1]); input.dispatchEvent(new Event('input', {bubbles: true})); """, input_el, "new value")

为什么要用原生value setter而不是直接input.value?因为直接赋值会被React内部的数据跟踪机制拦截。用setter赋值后,再dispatchEvent触发input事件,React的onChange才能正确收到变化。Vue框架的处理逻辑类似,触发input或change事件即可。Angular场景可能需要额外处理NgModel变更,不过大多数业务系统还是以React/Vue为主。

需要提醒的是,不要试图用JS去绕过受控组件的校验逻辑直接提交脏数据。该走的规则还是得走,不然自动化测试跑出来的结果和真实用户操作根本不是一回事。这种“强行赋值”应该只用在确实无法通过原生交互实现的少数场景。

4.3 几张“看似能行实则坑”的用例

有些JS操作看起来能走捷径,实际是坑。第一个是给file input设置value。想用document.getElementById('file').value = '/path/file.txt'来上传文件,浏览器会直接拒绝,因为file input的value是只读的,而且出于安全机制不允许JS赋值。文件上传只能用send_keys等原生方式,操作的是系统级别的文件选择框。

第二个是弹窗处理。execute_script调用window.confirm会返回布尔值,但它产生的弹窗路径和Selenium默认的alert处理方式可能不兼容。有时候会遇到弹窗出现后卡住的情况,需要用SwitchTo.alert配合,或者干脆在页面加载前通过JS把confirm/alert重写成固定返回值。

第三个是“赋值后立即断言页面校验状态”。有些表单校验规则是实时触发的,你通过JS赋值后,校验可能还没跑完,马上断言就会不稳定。正确做法是赋值后先用显式等待等校验结果稳定,再做断言。这些细节看起来不起眼,却直接影响自动化脚本的稳定性。

5. 高频脚本三:性能统计与页面状态监控

5.1 用performance接口拿页面加载耗时

Selenium本身适合功能测试,但如果你还需要给页面性能兜底,浏览器自带的Performance API配合execute_script就能拿到关键指标。最直观的是performance.timing对象:

timing = driver.execute_script(""" var t = window.performance.timing; return { domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart, load: t.loadEventEnd - t.navigationStart, requestStart: t.requestStart - t.navigationStart }; """)

更现代一点的API是performance.getEntriesByType('navigation')[0],返回PerformanceNavigationTiming对象,包含domContentLoadedEventEnd、loadEventEnd、transferSize等更完整的信息。注意这类对象不是普通JSON对象,直接返回会被序列化成空对象或部分字段,最好先用map手动提取需要的字段再return。

用法上,我通常会在页面完全加载后立即执行这段脚本,把结果保存到一个性能基线库里。如果某次版本上线后核心指标明显变差,自动化回归能第一时间发现问题。

5.2 拦截AJAX请求,统计接口耗时

没有浏览器插件的情况下,可以通过execute_script在页面上重写XMLHttpRequest的open/send方法,把请求URL和耗时记录下来。我给一个实用写法:

window.__ajaxLogs = []; var origOpen = XMLHttpRequest.prototype.open; var origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this.__url = url; this.__method = method; this.__startTime = Date.now(); origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function() { this.addEventListener('loadend', function() { window.__ajaxLogs.push({ method: this.__method, url: this.__url, duration: Date.now() - this.__startTime, status: this.status }); }); origSend.apply(this, arguments); };

注入这段脚本后,业务操作过程中所有AJAX请求都会记录到window.__ajaxLogs。之后再用一个execute_script读取数组即可。fetch请求需要另外包一层window.fetch。我实际项目中的做法是:完成登录后、进入业务主流程前注入,这样能统计到关键交互产生的接口耗时。

要注意,拦截器会在整个页面上下文一直生效。测试结束前最好清理掉,或者用唯一标记区分日志,否则下一次操作的请求记录会被混在一起,干扰分析。

5.3 MutationObserver等待动态内容出现

Selenium最常用的等待方式还是WebDriverWait加expected_conditions,但有些动态内容不是标准元素,或者需要等一组数据渲染完成。MutationObserver可以监听DOM变化,配合execute_async_script实现一个“等待指定文本出现再继续”的函数:

result = driver.execute_async_script(""" var text = arguments[0]; var done = arguments[1]; var target = document.body; var observer = new MutationObserver(function(mutations) { if (document.body.innerText.indexOf(text) !== -1) { observer.disconnect(); done(true); } }); observer.observe(target, {childList: true, subtree: true, characterData: true}); setTimeout(function() { observer.disconnect(); done(false); }, 10000); """, "加载完成")

在这个例子里,arguments[0]是调用时传入的目标文本,arguments[1]是Selenium自动追加的回调函数。脚本会观察body下所有子节点变化,一旦文本出现就回调true;10秒还没出现就主动断开观察并回调false,防止WebDriver一直挂起。

MutationObserver比较重,我不建议在大型页面上长时间挂观察者,用完立刻disconnect。它适合的场景是:你明确知道要等待某段动态内容,且用常规expected_conditions不好表达时,偶尔用一次效果很好。

6. 实战:滚动加载列表页的完整数据抓取脚本

6.1 场景拆解与整体思路

假设要采集一个滚动加载的列表页,页面初始有20条数据,往下滚动会陆续加载更多,直到全部加载完。整体思路分成四步:打开页面并等待首屏元素出现;用JS滚动到底部并等待数据渲染;循环执行直到页面高度不再增长;最后统一提取所有数据。

关键点是“如何判断加载完成”。用页面高度不再变化作为结束信号比较常见,但网络请求如果一直失败或者页面结构异常,高度可能始终不变,脚本就容易死循环。所以还必须加一个轮次上限,最多滚动多少次就强制停止,避免脚本无限跑下去。

6.2 完整可运行代码与关键注释

这里贴一份我常用的采集模板。为了保持通用,选择器用通用类名替代,直接替换成你目标页面的实际选择器即可。

from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import time driver = webdriver.Chrome() driver.get("某测试列表页") wait = WebDriverWait(driver, 10) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".product-item"))) def get_scroll_height(): return driver.execute_script("return document.documentElement.scrollHeight") last_height = get_scroll_height() max_scroll_rounds = 15 round_count = 0 while round_count < max_scroll_rounds: driver.execute_script("window.scrollTo(0, document.documentElement.scrollHeight);") time.sleep(2) new_height = get_scroll_height() if new_height == last_height: break last_height = new_height round_count += 1 items = driver.find_elements(By.CSS_SELECTOR, ".product-item") data = [] for item in items: row = driver.execute_script(""" var item = arguments[0]; return { title: item.querySelector(".title").innerText.trim(), price: item.querySelector(".price").innerText.trim(), link: item.querySelector("a").href }; """, item) data.append(row) for row in data: print(row) driver.quit()

代码里有两处细节值得说明。一是time.sleep(2)是保守等待,如果目标页面数据量很大,可以适当调长;更稳的做法是滚动后等待页面里的商品数量实际上增加了再继续,而不是固定睡两秒。二是用document.documentElement.scrollHeight而不是document.body.scrollHeight,因为标准模式页面中,前者的高度通常才是整个文档的完整高度。如果你发现滚动后高度没变但数据还在加载,换另一个值试试。

6.3 防死循环与数据校验的几个细节

靠scrollHeight判断结束,有一类特殊情况需要注意:有些页面打开弹窗后body高度会变化,滚动到底触发新数据时页面高度没变,但数据量增加了。这时候要增加一个“元素数量不再变化”的条件,把“高度没变”和“数量没变”同时作为结束条件,才更可靠。

last_count = len(driver.find_elements(By.CSS_SELECTOR, ".product-item")) # 在循环内滚动后重新统计数量 if new_height == last_height and current_count == last_count: break

另一个细节是,移动端页面中document.documentElement.scrollHeight和document.body.scrollHeight可能返回不同的值,而且哪个会变化因浏览器而异。我第一次接触时就被这个问题坑过,滚动半天高度永远不变,后来打了两个值对比才发现用错了对象。

数据校验方面,抓完数据之后我会在Python里先做一次去重和空值统计。对于滚动加载页面,翻页后之前的数据可能残留,导致重复采集。维护一个seen集合,确保同一商品链接只记录一次,再整体检查price字段是否为空。这些逻辑虽然不复杂,却是真实采集过程中最影响数据质量的环节。

7. 高频报错与避坑指南

7.1 JS语法/返回值类报错

execute_script最常见报错是JavascriptException,提示里通常带着具体原因,比如“xxx is not a function”。这类问题大部分是脚本语法错误,或者使用了不兼容的浏览器API。排查方法很简单:把这段JS原样复制到浏览器Console里执行,看有没有报错。Console里能跑通,Selenium里通常也能跑通。

另一个常见问题是字符串转义。用Python写JS字符串时,脚本内含单引号、双引号、反引号都有可能让代码跑飞。我的建议是:长脚本一律用三引号字符串包裹,内部优先用双引号;所有需要动态插入的变量,一律通过arguments传参,绝不直接拼字符串。这样既减少转义问题,也避免变量内容意外破坏脚本结构。

还有一种隐蔽坑是window.open这类操作会被浏览器当作弹窗拦截。自动化环境对弹窗的策略可能跟手动浏览器不一致,导致新页面打不开。这种场景可以在浏览器配置里开放弹窗权限,或者改成用a标签.click()的方式触发。

7.2 iframe、阴影DOM与跨文档操作

execute_script是在当前选中的frame的上下文里执行的。如果你的目标元素在iframe里,直接执行document.querySelector会找不到。正确顺序是先切换到iframe,再执行JS:

driver.switch_to.frame("iframe_name") driver.execute_script("return document.querySelector('.target');")

执行完iframe里的JS,需要switch_to.default_content()回到主文档,否则下一次execute_script仍然会在iframe上下文里执行,就可能出现主页面元素找不到的诡异现象。

阴影DOM是另一个容易踩坑的点。使用Web Component的页面,常规querySelector无法穿透shadowRoot。需要先拿到shadow host元素,再通过shadowRoot查询:

shadow_host = driver.find_element(By.CSS_SELECTOR, "my-component") driver.execute_script("return arguments[0].shadowRoot.querySelector('.inner-btn');", shadow_host)

这类问题在原生Selenium里基本无解,用execute_script处理反而干净利落。

7.3 状态不同步、元素未就绪的处理

execute_script执行完,并不代表页面渲染完成。尤其是修改了DOM或触发了AJAX之后,后续操作如果马上继续,经常会遇到元素还没出现、文本还是旧值的情况。这时候还是要搭配显式等待,让页面状态稳定后再做下一步。

我自己的习惯是,把常用的execute_script封装成几个工具函数。比如safe_click负责“原生click失败时自动降级为JS click”;get_text负责统一用JS取元素文本并清除空白字符;set_value负责处理React受控组件的赋值与事件触发。长脚本放进一个脚本常量字典,按功能命名复用。这样项目里遇到类似问题,只改一处就能全局生效。

在排查JS错误时注意一个细节:报错信息里的行号通常指脚本字符串内部的第几行,不是Python代码行号。这个提示容易误导人,我第一次看到就以为是Python代码某行出错了。把浏览器控制台的错误信息原样复制到Console里跑一遍,是最快的定位方式。

最后说一点我自己的体会。execute_script这个函数本身不难,难的是判断什么时候该用、什么时候不该用。我踩过两次坑,一次图省事想用JS直接绕过React组件的事件逻辑,结果页面看起来正常,提交时后端拿到的却是旧值;另一次过度依赖JS把隐藏字段打开,掩盖了真实操作可能遇到的交互问题,测试上线后才发现用户根本点不到那个控件。现在我的原则是:能用原生Selenium优先用原生,遇到原生确实搞不定或效率太低时,再用execute_script精准解决。写JS脚本也不要揉在一坨里,尽量拆成多个可复用的小函数,配合arguments传参,调试起来会顺手很多。这套高频脚本建议整理成自己的库,后面遇到复杂页面就不用从零开始造轮子了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进