简介:这是一份面向前端开发者与自动化办公实践者的问卷星表单自动填写工具包,解决重复性信息录入效率低、易出错的痛点,适用于在线调研、校园/企业批量注册、考试报名等需高频填写个人信息的场景。资源包含4个核心文件:主功能脚本wjx-auto-fill.user.js(基于Tampermonkey运行)、用于本地调试的index.html、.inscode配置文件及.gitignore,整体仅9KB,轻量易部署,适合JavaScript初学者理解DOM操作与用户脚本机制。目前已有278人学习下载,体现了社区对实用型小工具的持续关注。读者可直接安装脚本实现一键填充预设的姓名、手机号、邮箱等字段,代码结构清晰,含页面元素匹配逻辑与信息数组定义范式,便于二次扩展字段或适配其他表单平台,同时附带基础测试验证思路,助力快速上手与问题排查。
1. 问卷星自动填写脚本:不是“抢答神器”,而是表单交互逻辑逆向与 DOM 稳态控制的实战工程
你见过凌晨三点还在刷新问卷星页面、等一个“仅限前100名”的调研入口吗?某高校实验室做用户行为预实验时,需要模拟200个不同人口学标签的虚拟受访者,在同一份含12页跳转逻辑、5类条件题(单选/多选/矩阵/填空/上传)、3处防刷校验(时间戳+IP频次+行为序列)的问卷中完成有效提交——手动操作每人平均耗时8分42秒,误差率超37%。这时候,“问卷星自动填写脚本”根本不是什么玄学外挂,而是一套对前端渲染生命周期、表单状态机、异步校验钩子的精准卡点控制方案。它不绕过风控,不伪造UA,不暴力点击;它让脚本像真实用户一样等待DOM就绪、识别题干语义、响应动态加载、处理JS校验失败后的重试回退。适合正在做教育平台用户路径分析、市场调研数据采集自动化、或需要批量生成合规测试用例的一线产品/测试/数据工程师——前提是,你愿意花2小时读完这份拆到document.querySelector('[data-qid="q3"] input[type="radio"]:nth-child(2)')级的落地笔记。
2. 从页面结构逆向出发:为什么必须放弃“全量XPath”而转向语义化选择器
问卷星的HTML结构高度动态:题号ID随发布版本浮动(qid_12345→qid_67890),容器class名带哈希(question-item-abc123),甚至同一份问卷在PC端和H5端DOM树深度差3层。若用Selenium录屏后直接导出XPath,第2次运行必翻车。真正可靠的起点,是抓住三个不变量:题干文本的语义唯一性、题型控件的HTML特征模式、以及问卷星强制注入的>from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def find_question_by_text(driver, question_text, control_type="radio"): """ 根据题干文本定位题目区域,并返回指定类型的控件元素 control_type: 'radio', 'checkbox', 'text', 'textarea', 'file' """ # 先定位题干所在父容器(question-item) title_xpath = f"//div[@class='question-title' and contains(., '{question_text}')]/ancestor::div[contains(@class, 'question-item')]" try: question_container = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.XPATH, title_xpath)) ) except: raise ValueError(f"未找到题干包含 '{question_text}' 的题目") # 在该容器内按控件类型查找 if control_type == "radio": return question_container.find_elements(By.CSS_SELECTOR, "input[type='radio']") elif control_type == "checkbox": return question_container.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") elif control_type == "text": return question_container.find_element(By.CSS_SELECTOR, "input[type='text']") elif control_type == "textarea": return question_container.find_element(By.CSS_SELECTOR, "textarea") elif control_type == "file": return question_container.find_element(By.CSS_SELECTOR, "input[type='file']") else: raise ValueError(f"不支持的控件类型: {control_type}")
提示:
find_elements返回列表,find_element返回单个元素。矩阵题每行是独立<div class="matrix-row">,需先按行文本定位再取该行内的input,不能直接全量取。
2.2 利用>def extract_qid_mapping(driver): """提取当前问卷所有题目的>def wait_for_question_rendered(driver, question_text, timeout=15): """等待指定题干的控件实际渲染完成(非仅占位符)""" start_time = time.time() while time.time() - start_time < timeout: try: # 尝试获取题干下的具体控件(如radio按钮) elements = find_question_by_text(driver, question_text, "radio") if elements and len(elements) > 0: return True except: pass # 滚动到底部触发懒加载 driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(0.5) raise TimeoutError(f"等待题目 '{question_text}' 渲染超时") # 调用 wait_for_question_rendered(driver, "您对服务的总体满意度?")
3. 行为序列建模:把“人怎么填”翻译成可复现的原子动作流
问卷星的提交不是简单点击“下一题”,而是由一连串具有严格时序依赖的原子动作构成:聚焦输入框→输入文字→失焦触发校验→等待校验图标更新→判断是否跳转→处理弹窗。任何一步缺失,都会导致后续步骤失效。我们将其抽象为ActionStep类,每个实例封装一个不可分割的行为单元。
3.1 定义可组合的动作基类
from abc import ABC, abstractmethod import time class ActionStep(ABC): def __init__(self, description: str): self.description = description @abstractmethod def execute(self, driver) -> bool: """执行动作,返回是否成功""" pass class ClickStep(ActionStep): def __init__(self, locator, description="点击元素"): super().__init__(description) self.locator = locator def execute(self, driver): try: element = WebDriverWait(driver, 10).until( EC.element_to_be_clickable(self.locator) ) element.click() time.sleep(0.3) # 模拟人工停顿 return True except Exception as e: print(f"[失败] {self.description}: {e}") return False class InputTextStep(ActionStep): def __init__(self, locator, text, description="输入文本"): super().__init__(description) self.locator = locator self.text = text def execute(self, driver): try: element = WebDriverWait(driver, 10).until( EC.element_to_be_clickable(self.locator) ) element.clear() element.send_keys(self.text) # 失焦触发校验 driver.execute_script("arguments[0].blur();", element) time.sleep(0.5) return True except Exception as e: print(f"[失败] {self.description}: {e}") return False3.2 构建条件跳转题的动作流
以一道经典条件题为例:“Q3. 您是否使用过本产品?【是/否】→ 若选‘是’,显示Q4;若选‘否’,跳至Q6”。问卷星通过JS动态插入/移除DOM节点实现跳转,需在点击后等待目标题干出现:
class ConditionalJumpStep(ActionStep): def __init__(self, radio_locator, target_question_text, description="条件跳转"): super().__init__(description) self.radio_locator = radio_locator self.target_question_text = target_question_text def execute(self, driver): # 1. 点击选项 click_step = ClickStep(self.radio_locator) if not click_step.execute(driver): return False # 2. 等待目标题目渲染(最多等8秒) start = time.time() while time.time() - start < 8: try: driver.find_element(By.XPATH, f"//div[@class='question-title' and contains(., '{self.target_question_text}')]") return True except: time.sleep(0.3) print(f"[失败] 未在规定时间内加载目标题目:{self.target_question_text}") return False # 使用示例:Q3选“是”,则等待Q4出现 steps = [ ClickStep((By.XPATH, "//label[contains(., '是')]/preceding-sibling::input")), ConditionalJumpStep( (By.XPATH, "//label[contains(., '是')]/preceding-sibling::input"), "您使用本产品的频率是?" ), InputTextStep((By.CSS_SELECTOR, "input[type='text']"), "每天") ] for step in steps: if not step.execute(driver): break3.3 文件上传的沙箱隔离与路径标准化
问卷星文件上传控件为<input type="file">,Selenium无法直接操作OS对话框。必须绕过UI,用send_keys传入绝对路径。但Windows/macOS/Linux路径格式不同,且相对路径在Docker容器中会失效:
import os from pathlib import Path def get_absolute_upload_path(filename: str) -> str: """将上传文件名转为绝对路径,兼容多平台""" # 假设所有上传文件放在项目根目录下的 uploads/ 子目录 uploads_dir = Path(__file__).parent / "uploads" file_path = uploads_dir / filename if not file_path.exists(): raise FileNotFoundError(f"上传文件不存在: {file_path}") return str(file_path.resolve()) # 在动作流中使用 upload_step = InputTextStep( (By.CSS_SELECTOR, "input[type='file']"), get_absolute_upload_path("id_card.jpg") )注意:问卷星对上传文件有大小(≤10MB)、类型(仅允许jpg/png/pdf)、分辨率(≥300dpi)三重校验。脚本需提前校验,否则上传后JS报错不提示具体原因。
4. 防刷机制应对:不硬刚风控,而是模拟“人”的节奏与异常容忍
问卷星的防刷不是摆设。某次实测中,连续3次毫秒级点击“下一题”按钮,第4次触发429 Too Many Requests,IP被限流10分钟。真正的解法不是降速,而是理解其校验维度并针对性适配。
4.1 时间维度:引入正态分布停顿模型
固定间隔(如time.sleep(1.5))反而暴露机器特征。人类操作停顿符合正态分布:均值1.2秒,标准差0.4秒,且长停顿(>3秒)概率约5%。我们用numpy.random.normal生成自然停顿:
import numpy as np def human_pause(mean=1.2, std=0.4, min_pause=0.3, max_pause=5.0): """生成符合人类操作习惯的随机停顿时间(秒)""" pause = np.random.normal(mean, std) pause = max(min_pause, min(max_pause, pause)) time.sleep(pause) # 在每个ActionStep.execute末尾调用 def execute(self, driver): # ... 执行核心动作 human_pause() # 替代固定的 time.sleep(0.3) return True4.2 行为维度:添加鼠标移动轨迹模拟
纯element.click()无鼠标移动过程。用ActionChains模拟从题干到选项的缓慢移动:
from selenium.webdriver.common.action_chains import ActionChains def move_and_click(driver, element): """模拟人类鼠标移动轨迹后点击""" actions = ActionChains(driver) # 移动到题干,停顿 title_elem = element.find_element(By.CLASS_NAME, "question-title") actions.move_to_element(title_elem).pause(0.5) # 缓慢移动到选项 actions.move_to_element(element).pause(0.3) # 点击 actions.click().perform() time.sleep(0.4)4.3 异常维度:设计可恢复的失败重试机制
网络抖动、JS加载延迟、校验失败都可能导致单步中断。我们不全局重试,而是为每类动作定义专属恢复策略:
| 动作类型 | 典型失败现象 | 恢复策略 | 最大重试次数 |
|---|---|---|---|
| 点击按钮 | ElementClickInterceptedException | 滚动到视口 + 等待element_to_be_clickable | 3 |
| 输入文本 | InvalidElementStateException(输入框被禁用) | 等待element_to_be_enabled+ 重试失焦 | 2 |
| 条件跳转 | 目标题目未渲染 | 触发滚动 + 重查DOM | 3 |
| 文件上传 | FileNotFountException | 检查路径是否存在,打印绝对路径供调试 | 1 |
def robust_click(driver, locator, max_retries=3): for i in range(max_retries): try: element = WebDriverWait(driver, 10).until( EC.element_to_be_clickable(locator) ) driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element) time.sleep(0.2) element.click() return True except Exception as e: if i == max_retries - 1: print(f"点击失败,已重试{i+1}次: {e}") return False time.sleep(0.5 * (2 ** i)) # 指数退避 return False5. 避坑指南:那些让我重写三次脚本的血泪经验
问卷星前端迭代频繁,每次小版本更新都可能埋下新坑。以下5条是某跨平台系统集成中踩出的真实问题,按“现象→原因→解决”结构整理,每一条都附带可验证的检查命令。
5.1 现象:脚本在Chrome 120+上能运行,但在Edge 119上卡死在首页加载
原因:问卷星新版JS检测navigator.webdriver属性,Edge默认为true(即使未用WebDriver),而Chrome 120+已修复此行为。
解决:启动Edge时注入--disable-blink-features=AutomationControlled参数,并在页面加载后执行JS覆盖:
options = webdriver.EdgeOptions() options.add_argument("--disable-blink-features=AutomationControlled") driver = webdriver.Edge(options=options) driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', { 'source': 'Object.defineProperty(navigator, "webdriver", {get: () => undefined})' })5.2 现象:矩阵题所有行都填了同一选项(如全选“非常满意”)
原因:find_elements(By.CSS_SELECTOR, "input[type='radio']")获取的是全问卷所有单选框,未限定在当前矩阵题容器内。
解决:必须用题干定位容器,再在其内部查找:
# ✅ 正确:限定作用域 matrix_container = driver.find_element(By.XPATH, "//div[contains(@class,'matrix') and .//div[contains(.,'服务态度')]]") rows = matrix_container.find_elements(By.CLASS_NAME, "matrix-row") for row in rows: # 在每一行内找radio radios = row.find_elements(By.CSS_SELECTOR, "input[type='radio']") if radios: radios[0].click() # 选第一列5.3 现象:上传文件后页面显示“格式不支持”,但本地文件明明是JPG
原因:问卷星校验文件MIME类型,而Seleniumsend_keys传入路径时,浏览器读取的是文件头而非扩展名。某些工具导出的JPG文件头为JFIF,问卷星只认JPEG。
解决:用python-magic库校验并修复:
import magic def validate_and_fix_jpg(filepath): mime = magic.from_file(filepath, mime=True) if mime != "image/jpeg": # 用PIL无损重存,确保JFIF头转JPEG头 from PIL import Image img = Image.open(filepath) img.save(filepath, "JPEG", quality=95)5.4 现象:脚本运行到第7页突然所有按钮变灰不可点
原因:问卷星在页面底部注入<div id="survey-footer">,当其display: none时,顶部导航栏按钮会被JS设为disabled。而某些广告拦截插件会误杀该div。
解决:运行前注入JS确保footer存在且可见:
driver.execute_script(""" if (!document.getElementById('survey-footer')) { const footer = document.createElement('div'); footer.id = 'survey-footer'; footer.style.display = 'block'; document.body.appendChild(footer); } """)5.5 现象:同一份脚本在本地运行成功,部署到Linux服务器后总在“提交”按钮失败
原因:Linux服务器无GUI,Selenium默认使用Headless Chrome,但问卷星检测window.screen属性,Headless模式下screen.width/height为0。
解决:显式设置屏幕尺寸:
options = webdriver.ChromeOptions() options.add_argument("--headless=new") options.add_argument("--window-size=1920,1080") # 关键! options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage")6. 进阶技巧:用“快照比对”验证填写结果的语义正确性
自动化脚本最大的风险不是跑不起来,而是“跑起来了但填错了”。比如矩阵题本该填“服务态度:非常满意,响应速度:一般”,脚本却填成“服务态度:一般,响应速度:非常满意”。肉眼检查截图成本高,而DOM比对又太底层。我的做法是:在每页填写完成后,截取题干+用户所选答案的文本快照,与预设的JSON校验模板做语义比对。
6.1 构建页面快照提取器
def capture_page_snapshot(driver, page_num: int) -> dict: """提取当前页所有题干与答案的文本快照""" snapshot = {"page": page_num, "questions": []} # 获取当前页所有题目容器 question_containers = driver.find_elements( By.XPATH, "//div[contains(@class,'question-item') and not(contains(@style,'display:none'))]" ) for container in question_containers: try: title_elem = container.find_element(By.CLASS_NAME, "question-title") title_text = title_elem.text.strip() # 根据题型提取答案文本 answer_text = "" # 单选/多选 selected_labels = container.find_elements(By.CSS_SELECTOR, "label.selected, label.checked") if selected_labels: answer_text = " | ".join([lbl.text.strip() for lbl in selected_labels]) # 文本题 text_inputs = container.find_elements(By.CSS_SELECTOR, "input[type='text'], textarea") for inp in text_inputs: if inp.get_attribute("value").strip(): answer_text = inp.get_attribute("value").strip() break # 文件题(只记录存在) file_inputs = container.find_elements(By.CSS_SELECTOR, "input[type='file']") for inp in file_inputs: if inp.get_attribute("value"): answer_text = "[已上传文件]" break if title_text and answer_text: snapshot["questions"].append({ "title": title_text, "answer": answer_text }) except: continue return snapshot # 示例输出 # { # "page": 1, # "questions": [ # {"title": "您对服务的总体满意度?", "answer": "非常满意"}, # {"title": "请上传您的身份证照片", "answer": "[已上传文件]"} # ] # }6.2 设计语义校验模板与差异报告
创建expected_answers.json,按页定义期望答案(支持正则匹配文本):
{ "page_1": [ { "title": "您对服务的总体满意度?", "answer_pattern": "非常满意|满意|一般|不满意|非常不满意" }, { "title": "请上传您的身份证照片", "answer_pattern": "\\[已上传文件\\]" } ], "page_2": [ { "title": "您使用本产品的频率是?", "answer_pattern": "每天|每周.*?次|每月.*?次|很少使用" } ] }校验函数:
import re import json def validate_snapshot(snapshot: dict, template_path: str) -> list: """比对快照与模板,返回错误列表""" with open(template_path, 'r', encoding='utf-8') as f: template = json.load(f) errors = [] page_key = f"page_{snapshot['page']}" if page_key not in template: errors.append(f"模板中缺少第{snapshot['page']}页定义") return errors expected_questions = template[page_key] for exp_q in expected_questions: found = False for act_q in snapshot["questions"]: if exp_q["title"] in act_q["title"] or act_q["title"] in exp_q["title"]: # 模糊匹配题干(处理换行、空格差异) if not re.search(exp_q["answer_pattern"], act_q["answer"]): errors.append( f"题干'{act_q['title']}':期望'{exp_q['answer_pattern']}',实际'{act_q['answer']}'" ) found = True break if not found: errors.append(f"未在快照中找到题干匹配 '{exp_q['title']}'") return errors # 使用 snapshot = capture_page_snapshot(driver, 1) errors = validate_snapshot(snapshot, "expected_answers.json") if errors: print("❌ 页面语义校验失败:") for err in errors: print(f" {err}") else: print("✅ 页面填写语义正确")6.3 将快照比对嵌入CI/CD流水线
在GitLab CI中,每次问卷模板更新后自动触发脚本,生成快照并比对:
stages: - test test-questionnaire: stage: test image: python:3.11-slim before_script: - pip install selenium beautifulsoup4 python-magic script: - python snapshot_validator.py --url "$QUESTION_URL" --template expected_answers.json artifacts: paths: - snapshots/*.json - screenshots/*.png这样,当产品同学改了一个题干措辞(如“总体满意度”→“整体满意度”),校验就会失败并阻断发布,逼着所有人同步更新模板——这才是自动化该有的样子。
我坚持把每份问卷脚本的首次调试时间控制在2小时内:前30分钟跑通基础填写,后90分钟打磨语义校验与异常恢复。因为真正的效率不在于“跑得多快”,而在于“跑得有多稳”。当你的脚本能自证填对了,而不是靠人眼扫截图,你才算真正把问卷星变成了可编程的API。希望帮到你。
本文还有配套的精品资源,点击获取