资讯详情

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

📅 2026/10/22 5:29:41 | 华诺云谱 👁 阅读
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_scriptSelenium才算真正毕业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.displayblock;, 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下所有子节点变化一旦文本出现就回调true10秒还没出现就主动断开观察并回调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再执行JSdriver.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传参调试起来会顺手很多。这套高频脚本建议整理成自己的库后面遇到复杂页面就不用从零开始造轮子了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑