做酒店预订网站的自动化测试或抢房脚本时,日期选择器往往是最让人头疼的控件。表面上看它只是一个输入框加一个弹出的日历,但真正写起脚本来,你会发现各种意外:日历是异步加载的、目标日期要翻好几页才能看到、已经过去的日期被禁用点击后脚本直接报错。这篇文章以 Grange Hotels 这类酒店预订站点的日期选择器为背景,总结一套经过实践验证的可靠方案,覆盖元素定位、翻月处理、禁用日期规避和等待策略等关键环节。

先搞清楚日期选择器的 DOM 结构再动手
拿到任何一个日期控件,第一件事不是写代码,而是打开浏览器开发者工具观察它的 DOM 结构。目前主流的日历控件大致分三类:原生 <input type="date">、基于 JavaScript 渲染的第三方日历组件(如 jQuery UI Datepicker、flatpickr、react-datepicker),以及酒店站点常见的自定义日历面板。Grange Hotels 这类预订站大多使用自定义或半定制的日历面板,DOM 结构通常是一个表格或一组 div,每个月份是一个容器,日期单元格上会通过 class 标记可选、已满房、禁用等状态。
观察时重点记录三个信息:第一,输入框本身是只读的还是可以键入的;第二,日历面板是在点击后动态插入 DOM,还是页面加载时就已存在只是被隐藏;第三,禁用日期的标识方式,常见的有 disabled 属性、aria-disabled="true"、class 中带有 unavailable 或 off 之类的字样。这三个信息直接决定后面采用哪种自动化策略。
还有一个容易被忽略的细节:不少日历控件的日期单元格只显示数字(比如 15),光靠文本定位会命中多个月份里的同一天。因此定位时必须把月份容器作为上下文,先锁定月份,再在月份内找日期,而不是全页面搜索数字。
两种主流思路:直接赋值与模拟点击的取舍
第一种思路是不碰日历面板,直接对输入框操作。如果输入框不是只读的,可以用 JavaScript 直接设置值并触发事件:
// 用 JS 直接注入日期并触发事件(适用于非只读输入框)
const js = `
const el = arguments[0];
el.value = arguments[1];
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
`;
driver.executeScript(js, dateInput, '2025-08-15');这种方式的优点是快,不依赖日历渲染,稳定性最高;缺点是绕过了页面的交互逻辑,如果前端框架(比如 React)对表单做了受控处理,直接改 value 可能不会生效,需要再派发框架监听的事件。对于只读输入框,则必须先移除 readonly 属性,这又增加了被前端校验拒绝的风险。
第二种思路是模拟真实用户行为:点击输入框弹出日历,翻到目标月份,再点击目标日期。这种方式最接近真实用户操作,能顺带验证日历本身的可用性,缺点是链路长、每一步都可能出错。实践中建议的组合策略是:生产环境的回归测试用模拟点击保证真实性,纯数据流程类脚本用直接赋值换取速度。下面的代码演示了模拟点击方案中翻月的核心逻辑:
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def goto_month(driver, target_year, target_month):
# 每次循环检查当前展示的月份,决定往前还是往后翻
for _ in range(24): # 最多翻两年,防止死循环
header = driver.find_element(By.CSS_SELECTOR, ".datepicker--header").text
current = datetime.strptime(header.strip(), "%B %Y")
if (current.year, current.month) == (target_year, target_month):
return
next_btn = WebDriverWait(driver, 5).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, ".datepicker--nav-next")))
if (current.year, current.month) < (target_year, target_month):
next_btn.click()
else:
driver.find_element(By.CSS_SELECTOR, ".datepicker--nav-prev").click()
raise TimeoutError("未能在限定步数内翻到目标月份")注意这里没有用 time.sleep 硬等待,而是每次点击后重新读取月份标题判断状态。硬等待是自动化脚本偶发失败的头号元凶,能用条件判断替代的都应当替代。
处理禁用日期、满房状态与等待策略
酒店日历和普通日历最大的区别在于:不是所有未来日期都可选。已过去的日期、已满房的日期、最短入住天数限制,都会导致某个日期点了没反应或者直接不可点。如果不做判断,脚本会在这些日期上卡死或误选错误日期。稳妥的做法是点击前先检查单元格状态:
def pick_date(driver, day):
cells = driver.find_elements(
By.CSS_SELECTOR, ".datepicker--cell-date:not(.-disabled-)")
for cell in cells:
if cell.text.strip() == str(day):
# 二次确认该单元格确实处于当前目标月份内
if "-other-month-" in cell.get_attribute("class"):
continue
driver.execute_script("arguments[0].click();", cell)
return
raise ValueError(f"日期 {day} 不可选或未找到")这里用了 JavaScript 的 click() 而不是 Selenium 原生点击,原因在于日历面板经常被其他浮层遮挡,原生点击会抛出 ElementClickInterceptedException。用 JS 点击可以绕开遮挡检查,当然前提是你已经确认这个点击在业务上是安全的。
等待策略上,推荐三层结构:等待日历面板出现用显式等待 visibility_of_element_located;等待翻月动画结束用状态轮询(检查月份标题是否变化);等待选择结果生效用输入框值变化判断。三层等待各司其职,比统一设置一个几秒钟的大 sleep 既快又稳。另外要留意日历面板的动画效果,有些组件翻月时有 300 毫秒左右的过渡动画,在动画结束前点击新月份的日期会落到旧面板上,这时可以用 CSS 判断 transition 相关 class 是否移除,或者简单地在翻月后重新定位一次日期单元格。
封装成可复用的日期选择器工具类
最后一步是把上面的逻辑沉淀成工具类或独立函数,供所有预订流程复用。一个设计良好的封装应该只暴露三个参数:入住日期、离店日期、以及可选的容错策略。内部自动完成打开日历、翻月、选日、验证输入框值这整套动作,并在任何一步失败时给出清晰的错误信息,比如“目标日期 2025-08-15 在日历中处于禁用状态”,而不是笼统的 NoSuchElementException。
如果你使用的是 Playwright,事情会简单不少。Playwright 的自动等待机制内置了元素可操作性检查,而且 get_by_role 配合 ARIA 标签定位日历单元格的成功率明显高于纯 CSS 选择器,因为现代日历组件基本都实现了 role="gridcell" 语义。无论用哪个工具,核心原则不变:永远基于状态判断驱动流程,永远对禁用项做防御,永远不要依赖固定时长的睡眠。掌握这套思路后,Grange Hotels 这类站点的日期自动化不再靠运气,而是每一次都稳定复现。