返回博客

使用Scrapeless Scraping浏览器进行表单输入验证

Daniel Kim
Daniel Kim

Lead Scraping Automation Engineer

11-Aug-2026

TL;DR:

  • 直接赋值 element.value 并不会触发任何事件。 在一次测量运行中,直接赋值产生了一个空事件列表,而 locator.fill() 则产生了 focusbeforeinputinput — 无论如何,该值都进入了 DOM,因此失败是无声的。
  • 直接赋值仍满足 HTML5 约束验证,并且仍然填充 FormData 设置 value 并不会被浏览器拒绝;它仅仅没有被观察到,破坏的是任何对此做出反应的页面逻辑。
  • 真正出错的字段是其他字段依赖的字段。 直接设置 <select> 的值使得依赖输入隐藏,提交按钮禁用,而 selectOption() 则同时显示了这两个。
  • JavaScript 日期选择器可能会丢弃你输入的内容。 在一个公共日期字段上,填写后按 Escape 或点击其他地方将字段返回为空字符串;使用 Enter 提交时则生成小部件自己规范化的格式。
  • setInputFiles() 即使浏览器不在你的机器上也能工作。 Playwright 将本地文件流传输到远程会话,上传出现在提交的表单数据中。
  • 验证错误在没有截图的情况下可读。 validityvalidationMessage 和被阻止的 requestSubmit() 精确地暴露了哪个约束失败以及原因。
  • 免费开始。 新的 Scrapeless 帐户包括免费的 Scraping Browser 运行时 — 请在 app.scrapeless.com 注册。

为什么一个填充的字段仍然可以是一个空表单

一个表单字段可以包含正确的文本,但仍然提交无用的信息。值存在于 DOM 中,截图看起来正确,而页面的行为就像这个字段从未被触摸过 — 下一个字段从未出现,提交按钮保持禁用,或者页面自行渲染的验证消息从未清除。

原因不在于值。原因在于与其同时从未调度的事件。现代表单在提交时很少读取原始 DOM 值;它们订阅 inputchange,保留自己的状态副本,并从该副本推动其他一切。写入 .value 时,你更新了 DOM,却让每个订阅者毫无察觉。

本指南测量这种行为而不是断言它,然后逐步处理小部件类,其中差距实际上会显现:依赖选择、JavaScript 日期选择器、文件输入,以及那些约束直接拒绝提交的表单。下面的所有内容都在连接到 Scrapeless Scraping Browser 的 Playwright 上运行,这是一个可定制的、反检测的云浏览器,由自主开发的 Chromium 提供支持。如果你需要先了解填充和提交的基础知识,Puppeteer 表单提交指南 涵盖了这些内容。


先决条件

  • Node.js 18 或更新版本
  • 一个 Scrapeless 账户和 API 密钥 — 请在 app.scrapeless.com 注册
  • 对 CSS 选择器和 DOM 事件模型的工作知识

安装

浏览器是远程的,因此 playwright-core 是你所需的全部 — 不需要捆绑的 Chromium 下载:

bash Copy
pnpm add playwright-core@1.56.1

从环境中设置你的 API 密钥,而不是将其放入源代码:

bash Copy
export SCRAPELESS_API_KEY="paste-your-key-here"

配置:将 Playwright 连接到云浏览器

Scraping Browser 使用 Chrome DevTools 协议,因此 connectOverCDP 是入口点。下面的公共测试表单属于 Selenium 项目,专门用于自动化练习:

js Copy
import { chromium } from 'playwright-core';

const endpoint = 'wss://browser.scrapeless.com/api/v2/browser?' + new URLSearchParams({
  token: process.env.SCRAPELESS_API_KEY,
  sessionTTL: '300',
});

const browser = await chromium.connectOverCDP(endpoint);
const context = browser.contexts()[0];
const page = context.pages()[0] || await context.newPage();

await page.goto('https://www.selenium.dev/selenium/web/web-form.html', {
  waitUntil: 'domcontentloaded',
});

console.log('chromium:', browser.version());
console.log('title:', await page.title());
console.log('form method:', await page.getAttribute('form', 'method'));
await browser.close();
text Copy
chromium: 140.0.7339.35
title: Web form
form method: get

关于远程会话的一点说明:默认页面强制执行受信任类型,因此 page.setContent() 在写入标记之前会被拒绝。导航到 data: URL 是可行的替代方案,下面的测量正是使用这个。


基本实现:测量每种技术发出的内容

与其依赖经验法则,不如给输入装置。这个夹具记录到达字段的每个事件,同时还从 input 监听器中渲染自己的验证消息 — 这种模式使得无声的失败变得可见:

js Copy
import { chromium } from 'playwright-core';

const endpoint = 'wss://browser.scrapeless.com/api/v2/browser?' + new URLSearchParams({
  token: process.env.SCRAPELESS_API_KEY,
  sessionTTL: '300',
});
const browser = await chromium.connectOverCDP(endpoint);
const context = browser.contexts()[0];
const page = context.pages()[0] || await context.newPage();

const fixture = `<!doctype html><meta charset=utf-8><title>fixture</title>
<form id=f>
  <input id=t name=t type=text required minlength=4>
  <output id=err></output>
</form>
<script>
window.__ev = [];
var el = document.getElementById('t');
['focus','beforeinput','input','change','keydown','keyup'].forEach(function(type){
  el.addEventListener(type, function(){ window.__ev.push(type); });
});
el.addEventListener('input', function(){
  document.getElementById('err').textContent = el.validity.valid ? 'ok' : 'too short';
});
</script>`;
const fixtureUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(fixture);

async function trial(name, action) {
  await page.goto(fixtureUrl, { waitUntil: 'load' });
  await action();
  await page.waitForTimeout(200);
  const state = await page.evaluate(() => ({
    events: window.__ev.slice(),
    formData: new FormData(document.getElementById('f')).get('t'),
    valid: document.getElementById('t').validity.valid,
    errorUi: document.getElementById('err').textContent,
  }));
  console.log(name);
  console.log('  events  :', JSON.stringify(state.events));
  console.log('  formData:', JSON.stringify(state.formData), '| valid:', state.valid);
  console.log('  error-UI:', JSON.stringify(state.errorUi));
}

await trial('A: el.value = "hello"', () =>
  page.$eval('#t', el => { el.value = 'hello'; }));

await trial('B: el.value + dispatchEvent(input)', () =>
  page.$eval('#t', el => {
    el.value = 'hello';
    el.dispatchEvent(new Event('input', { bubbles: true }));
  }));

await trial('C: locator.fill("hello")', () => page.fill('#t', 'hello'));

await trial('D: locator.pressSequentially("hello")', () =>
  page.locator('#t').pressSequentially('hello'));

await browser.close();
text Copy
A: el.value = "hello"
  events  : []
  formData: "hello" | valid: true
  error-UI: ""
B: el.value + dispatchEvent(input)
  events  : ["input"]
  formData: "hello" | valid: true
  error-UI: "ok"
C: locator.fill("hello")
  events  : ["focus","beforeinput","input"]
  formData: "hello" | valid: true
  error-UI: "ok"
D: locator.pressSequentially("hello")
  events  : ["focus","keydown","beforeinput","input","keyup","keydown","beforeinput","input","keyup","keydown","beforeinput","input","keyup","keydown","beforeinput","input","keyup","keydown","beforeinput","input","keyup"]
  formData: "hello" | valid: true
  error-UI: "ok"

仔细阅读第一个案例。直接赋值产生了一个 事件列表,但 FormData 却携带 "hello",而字段报告其自身有效。值确实在那里。未发生的是页面自身的反应:错误输出保持为空,因为没有人告诉它更新。

这个结果重新审视了通常的建议。设置 .value 并非天生有缺陷 — 它是不可见的。一个没有脚本的普通 HTML 表单正确地提交了它。一个行为与事件连接的表单将字段视为未触及,症状完全出现在其他地方。
fill() 产生大多数页面所需的紧凑序列:聚焦,然后 beforeinput,再然后 inputpressSequentially() 产生完整的每字符键盘序列,这在页面检查单独的按键时非常重要,例如在每个字母上查询的自动完成。 W3C UI 事件规范 定义了这些技术模仿的顺序,而 WHATWG DOM 标准 定义了手动调度事件的传播方式——包括isTrusted标志,它将其与实际用户操作分开。

在免费计划上获取您的 API 密钥:app.scrapeless.com


高级模式:需要的不仅仅是一个值的小部件

解锁其他字段的选择框

在这里,直接赋值不再仅仅是不可见的,而是开始浪费您的数据。下面的装置揭示了第二个字段,并通过选择的change处理程序启用提交按钮:

js Copy
import { chromium } from 'playwright-core';

const endpoint = 'wss://browser.scrapeless.com/api/v2/browser?' + new URLSearchParams({
  token: process.env.SCRAPELESS_API_KEY,
  sessionTTL: '300',
});
const browser = await chromium.connectOverCDP(endpoint);
const context = browser.contexts()[0];
const page = context.pages()[0] || await context.newPage();

const fixture = `<!doctype html><meta charset=utf-8><title>conditional</title>
<form id=f>
  <select id=plan name=plan>
    <option value="">choose</option>
    <option value="basic">Basic</option>
    <option value="team">Team</option>
  </select>
  <div id=extra hidden><input id=seats name=seats type=number></div>
  <button id=go type=submit disabled>Continue</button>
</form>
<script>
var plan = document.getElementById('plan');
plan.addEventListener('change', function(){
  document.getElementById('extra').hidden = (plan.value !== 'team');
  document.getElementById('go').disabled = !plan.value;
});
</script>`;
const url = 'data:text/html;charset=utf-8,' + encodeURIComponent(fixture);

async function trial(name, action) {
  await page.goto(url, { waitUntil: 'load' });
  await action();
  await page.waitForTimeout(200);
  console.log(name, '->', JSON.stringify(await page.evaluate(() => ({
    selectValue: document.getElementById('plan').value,
    seatsVisible: !document.getElementById('extra').hidden,
    submitEnabled: !document.getElementById('go').disabled,
  }))));
}

await trial('A: select.value = "team"', () =>
  page.$eval('#plan', el => { el.value = 'team'; }));

await trial('B: select.value + dispatchEvent(change)', () =>
  page.$eval('#plan', el => {
    el.value = 'team';
    el.dispatchEvent(new Event('change', { bubbles: true }));
  }));

await trial('C: locator.selectOption("team")', async () => {
  await page.selectOption('#plan', 'team');
  await page.waitForSelector('#seats', { state: 'visible' });
  await page.fill('#seats', '25');
});

await browser.close();
text Copy
A: select.value = "team" -> {"selectValue":"team","seatsVisible":false,"submitEnabled":false}
B: select.value + dispatchEvent(change) -> {"selectValue":"team","seatsVisible":true,"submitEnabled":true}
C: locator.selectOption("team") -> {"selectValue":"team","seatsVisible":true,"submitEnabled":true}

案例 A 是值得记住的失败。选择真实地持有"team",所以验证其自身工作的脚本通过读取值来报告成功——而它应该填充的座位字段仍然是隐藏的,应该点击的按钮仍然是禁用的。 selectOption() 避免了整个问题类别,因为它以用户选择的方式调度inputchange

复选框和单选框

使用check()uncheck()而不是click()。它们声称结果状态,而不是盲目切换,因此在标记中已经携带checked的框不会被错误翻转:

js Copy
await page.check('#my-check-2');
await page.uncheck('#my-check-1');
await page.check('#my-radio-2');

丢弃您输入内容的日期选择器

看起来像日期输入的字段通常是一个带有 JavaScript 日历的文本输入,而日历拥有值。在一个公共日期字段中输入同一天的六种方法产生了四种不同的存储值:

技术 之后字段值 悬浮层
fill('2026-08-06') "2026-08-06" 仍然打开
fill(...) 然后 Escape "" 关闭
fill(...) 然后点击其他地方 "" 关闭
fill(...) 然后 Enter "08/06/2026" 仍然打开
el.value = '2026-08-06' "2026-08-06" 从未打开
pressSequentially('2026-08-06') 然后 Escape "10/08/6" 关闭

其中三个是陷阱。用 Escape 关闭悬浮层或点击其他地方将字段恢复为空字符串,因为小部件将关闭视为放弃的编辑。逐字符输入使日历在输入时重新格式化字段,并产生一个与任何内容都不匹配的值。直接赋值仅保留了文字文本,因为小部件根本没有参与——这意味着日历的内部状态和字段不一致。

使用 Enter 提交是有效的结束,小部件会将值写入其自己的显示格式。验证提交的值,而不是假设您的输入得以保留:

js Copy
await page.fill('input[name="my-date"]', '2026-08-06');
await page.press('input[name="my-date"]', 'Enter');
console.log(await page.inputValue('input[name="my-date"]'));
// 08/06/2026

在使用真正的<input type="date">的地方,情况更简单:该值必须是 ISO YYYY-MM-DD 字符串,无论显示给用户的格式如何。

在非本地浏览器上的文件输入

对云浏览器的一个合理担忧是文件上传可能无法工作,因为文件存储在您的机器上,而浏览器则不在。setInputFiles() 处理协议上的传输,因此从不涉及操作系统对话框:

js Copy
import fs from 'node:fs';
import { chromium } from 'playwright-core';

fs.writeFileSync('upload-sample.txt', 'scrapeless form upload fixture\n');

const endpoint = 'wss://browser.scrapeless.com/api/v2/browser?' + new URLSearchParams({
  token: process.env.SCRAPELESS_API_KEY,
  sessionTTL: '300',
});
const browser = await chromium.connectOverCDP(endpoint);
const context = browser.contexts()[0];
const page = context.pages()[0] || await context.newPage();

await page.goto('https://www.selenium.dev/selenium/web/web-form.html', {
  waitUntil: 'domcontentloaded',
});

await page.setInputFiles('input[name="my-file"]', './upload-sample.txt');

console.log(await page.$eval('input[name="my-file"]', el => ({
  files: el.files.length,
  name: el.files[0].name,
  hasBytes: el.files[0].size > 0,
})));

await Promise.all([
  page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
  page.click('button[type="submit"]'),
]);
console.log('submitted my-file:',
  JSON.stringify(new URL(page.url()).searchParams.get('my-file')));
await browser.close();
text Copy
{ files: 1, name: 'upload-sample.txt', hasBytes: true }
submitted my-file: "upload-sample.txt"

远程会话收到文件,FileList 已填充,文件名出现在提交的表单数据中。

多步骤表单

将每一步视为一个前提,而不是延迟。在提交推动表单的字段后,等待仅存在于下一步的控件再进行接触:

js Copy
await page.selectOption('#plan', 'team');
await page.waitForSelector('#seats', { state: 'visible' });
await page.fill('#seats', '25');

对元素本身的等待使脚本与表单的真实状态保持一致,这也是使条件选择案例工作的相同原理。


阅读提交的状态

唯一的权威确认是服务器收到的内容。该表单通过 GET 提交,因此接受的值出现在下一个页面的查询字符串中:

js Copy
import { chromium } from 'playwright-core';

const endpoint = 'wss://browser.scrapeless.com/api/v2/browser?' + new URLSearchParams({
  token: process.env.SCRAPELESS_API_KEY,
  sessionTTL: '300',
});
const browser = await chromium.connectOverCDP(endpoint);
const context = browser.contexts()[0];
const page = context.pages()[0] || await context.newPage();

await page.goto('https://www.selenium.dev/selenium/web/web-form.html', {
  waitUntil: 'domcontentloaded',
});

await page.fill('#my-text-id', 'Ada Lovelace');
await page.selectOption('select[name="my-select"]', '2');
await page.check('#my-check-2');
await page.uncheck('#my-check-1');
await page.check('#my-radio-2');
await page.fill('input[name="my-date"]', '2026-08-06');
await page.press('input[name="my-date"]', 'Enter');

await Promise.all([
  page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
  page.click('button[type="submit"]'),
]);

const params = Object.fromEntries(new URL(page.url()).searchParams.entries());
for (const key of ['my-text', 'my-select', 'my-check', 'my-date']) {
  console.log(key.padEnd(10), JSON.stringify(params[key]));
}
await browser.close();
text Copy
my-text    "Ada Lovelace"
my-select  "2"
my-check   "on"
my-date    "08/06/2026"

检查服务器的视图告诉您表单已被接受,而不仅仅是字段已被填充。请注意,my-date携带小部件提交的格式,而不是输入的字符串。


从 DOM 中读取验证错误

当提交被拒绝时,浏览器已经提供了一个结构化的解释。 HTML标准中的约束验证API 表示哪个规则失败以及浏览器将显示的消息,因此无需截屏页面并读取像素:

js Copy
import { chromium } from 'playwright-core';

const endpoint = 'wss://browser.scrapeless.com/api/v2/browser?' + new URLSearchParams({
  token: process.env.SCRAPELESS_API_KEY,
  sessionTTL: '300',
});
const browser = await chromium.connectOverCDP(endpoint);
const context = browser.contexts()[0];
const page = context.pages()[0] || await context.newPage();

const fixture = `<!doctype html><meta charset=utf-8><title>validation</title>
<form id=f>
  <input id=email name=email type=email required>
  <input id=code name=code type=text required minlength=4>
  <button type=submit>Send</button>
</form>
<script>
window.__submitted = false;
document.getElementById('f').addEventListener('submit', function(e){
  e.preventDefault(); window.__submitted = true;
});
</script>`;
await page.goto('data:text/html;charset=utf-8,' + encodeURIComponent(fixture),
  { waitUntil: 'load' });

await page.fill('#email', 'not-an-address');
await page.fill('#code', 'ab');

const report = await page.evaluate(() => {
  const form = document.getElementById('f');
  form.requestSubmit();
  return {
    submitted: window.__submitted,
    fields: [...form.elements]
      .filter(el => el.willValidate && !el.checkValidity())
      .map(el => ({
        name: el.name,
        message: el.validationMessage,
        failed: Object.keys(ValidityState.prototype)
          .filter(k => k !== 'valid' && el.validity[k]),
      })),
  };
});
console.log(JSON.stringify(report, null, 1));
await browser.close();
text Copy
{
 "submitted": false,
 "fields": [
  {
   "name": "email",
   "message": "Please include an '@' in the email address. 'not-an-address' is missing an '@'.",
   "failed": [
    "typeMismatch"
   ]
  },
  {
   "name": "code",
   "message": "Please lengthen this text to 4 characters or more (you are currently using 2 characters).",
   "failed": [
    "tooShort"
   ]
  }
 ]
}

submittedfalse 因为浏览器在处理程序运行之前阻止了提交,并且每个失败字段都说明其自身的破碎规则。这为您提供了一个机器可读的日志理由,而不是缺乏结果的无解释。 关于客户端表单验证的MDN参考 涵盖这些标志所映射到的更广泛的约束集。

渲染自己错误文本的页面而不是依赖于浏览器是第一个测量已经解释的案例:只有当它们监听的事件被触发时,这些消息才会出现。


本地浏览器耗尽的地方

以上每种技术都是普通的Playwright,并且也可以在本地的Chromium上运行。 Playwright输入文档 描述了相同的方法。本地浏览器无法提供给您的是一种一致的环境,以便对表单进行评估。

表单是网站中与欺诈和滥用控制最密切相关的部分,这些控制关注的是会话而不是标记:出口地址、指纹,以及浏览器是否表现为真正的安装。这就是Scrapeless Scraping Browser提供的部分:一个可定制的反检测云浏览器,由自行开发的Chromium驱动,通过诸如 sessionTTL 等参数在连接时按会话配置,所有这些都通过您代码已使用的相同 connectOverCDP 调用。小部件处理中的内容没有变化;只有环境发生变化。 Scraping Browser产品页面文档 涵盖了完整的会话表面。


故障排除

症状 原因 修复
字段保持值但页面上没有其他反应 直接 .value 赋值未触发任何事件 使用 fill(),或者显式地触发 inputchange
依赖字段从未出现 显示它的 change 处理程序从未运行 使用 selectOption(),然后等待依赖选择器
提交按钮保持禁用状态 页面从您未发出的事件中启用它 使用定位器方法驱动控制字段
填写后日期字段为空 选择器将关闭视为放弃编辑 使用 Enter 提交并读取值
日期值看起来杂乱 日历在输入之间格式化了字段 使用 fill() 加上 Enter 而不是逐字符输入
page.setContent() 被拒绝 默认页面强制执行受信任类型 改为导航到 data: URL
提交悄无声息 约束验证阻止了它 读取每个字段的 validationMessagevalidity

结论

看似神秘的表单自动化问题通常是一个可量化的事情:一个存在于DOM中但缺少让页面注意到的事件的值。直接赋值设置了值,满足约束验证,并填充 FormData —— 但告诉没人。定位器方法如 fillcheckselectOption 发出页面正在监听的信号,这就是它们在表单增加依赖字段、选择器小部件或验证层时仍然有效的原因。

养成确认结果而不是输入的习惯:从小部件中读取提交的值,等待应当被显示的元素,并在提交无效时检查 validity。在Scrapeless Scraping Browser上运行使环境保持一致,因此唯一留下的变量是您的选择器逻辑。


准备构建您的人工智能驱动的数据管道?

加入社区以获得免费的计划,并与开发者比较自动化表单驱动工作流程的笔记: Discord · Telegram
app.scrapeless.com 注册以获取免费的 Scraping Browser 运行时,并查看 定价 在你扩大规模时。


常见问题解答

问:直接设置 element.value 有效吗?
是的,对于在提交时读取 DOM 的表单。在测量运行中,直接赋值填充了 FormData 并通过了约束验证。它在页面保持自己状态的副本或从 inputchange 监听器渲染任何内容的地方失败,因为赋值没有派发事件。

问:如果我必须手动设置一个值,我需要派发哪些事件?
为文本字段派发一个冒泡的 input 事件,为选择框、复选框和单选框派发一个冒泡的 change 事件。一些框架跟踪元素上的前一个值,并忽略值他们认为已经记录的事件;在派发之前调用原生原型设置器可以避免这个问题。使用 fill()check(),或 selectOption() 完全避免了这个问题。

问:我填写日期字段后为什么是空的?
一个 JavaScript 日历小部件很可能将其恢复。填充字段之后按 Escape 或点击其他地方在测试中返回了一个空字符串,因为小部件将被解除的覆盖视为放弃的编辑。使用 Enter 提交值并在提交之前读取它。

问:当浏览器在云中运行时,我可以上传文件吗?
可以。 setInputFiles() 通过协议将本地文件传输到远程会话,因此不涉及操作系统文件对话框。填充的 FileList 和提交的表单数据都确认了上传。

问:我怎么知道表单为什么拒绝提交?
迭代表单的元素,筛选出 checkValidity() 返回 false 的元素,然后读取每个元素的 validationMessagevalidity 标志。这使你能够确定特定的失败约束,例如 typeMismatchtooShort,而无需检查渲染的像素。

问:我应该使用 fill() 还是逐字符输入?
优先使用 fill()。它发出焦点、beforeinputinput,这是几乎所有表单监听的内容。只有在页面对单个按键反应时才可以考虑逐字符输入,例如一个在每个字母上查询的自动完成功能 — 并检查结果,因为一些小部件会在按键之间重新格式化字段。

在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。

最受欢迎的文章

目录