Zap ⚡ — a tiny web-form classifier for autofill

Zap looks at a web form and tells a password manager what every input wants and what the form is for. It runs locally, has 20.7M parameters, and ships bf16 weights (41 MB), plus ONNX files (fp32 83 MB, int8 21 MB).

  • Field types (26): username, email, current-password, new-password, one-time-code, tel, names, address parts, payment-card parts, bday, search, captcha, other
  • Form purposes (11): login, signup, password-change, password-recovery, otp, payment, address, newsletter, contact, search, other

So your autofill can answer questions like "is this a login form?", "which box gets the saved password, and which one gets a newly generated password?", "is this the email field, or is it a honeypot?"

Held-out websites: 6,838 real forms and 19,811 fields from Common Crawl pages on hosts never seen in training:

Zap keyword heuristic¹
field accuracy (26 classes) 97.2% 82.7%
field macro-F1 0.945 0.723
form-purpose accuracy (11 classes) 95.7% 71.6%
password-field detection F1 0.996 0.968
current vs new password (on password fields) 99.2% 85.5%
login-identifier (username/email) F1 0.994 0.945
autocomplete removed from the input: recovers the developer's own token² 97.8% 78.7%

¹ A baseline of the kind password managers ship: it trusts a valid autocomplete, then the input type, then multilingual keyword regexes over name/id/placeholder/label/nearby text. ² On 1,622 test fields where the site's autocomplete token agrees with the reference label, the token is deleted from the model input. The score is how often Zap still predicts it. This check doesn't depend on the LLM teacher.

Quick start (Python)

# pip install transformers torch lxml selectolax huggingface_hub
import sys
from huggingface_hub import snapshot_download
sys.path.insert(0, snapshot_download("ProCreations/Zap"))
from zap_infer import Zap

zap = Zap.from_pretrained("ProCreations/Zap")          # CPU, fp32 compute from the bf16 weights
for form in zap.classify_html(html, url="https://northwind.example/login"):
    print(form["form"], round(form["form_score"], 3))
    for f in form["fields"]:
        print("   ", f["type"], f["name"], "->", f["label"], round(f["score"], 3))

Output for a page that has a sign-in form and a separate "New here? Create account" block:

login 0.966
    input text login -> username 0.96
    input password pw -> current-password 0.962
signup 0.961
    input text email -> email 0.957
    input password pass 1 -> new-password 0.956
    input password pass 2 -> new-password 0.955

Neither block sets a useful autocomplete attribute: the login box has autocomplete="off", and the signup inputs are typeless <input>s with placeholders. Zap tells them apart from context.

In a browser extension (JavaScript)

Zap doesn't read raw HTML. It reads a compact text rendering of each form: page title, URL words, form attributes, heading, buttons and links, and for every field its type, name, id, autocomplete, placeholder, label, nearby text and so on. zap-extract.js builds that rendering from the live DOM, so it sees exactly what the model was trained on. zap-infer.js handles windowing and decoding with transformers.js.

import * as transformers from "@huggingface/transformers";
// load zap-extract.js and zap-infer.js first (content scripts), or require() them in Node
const zap = await ZapModel.load(transformers, "ProCreations/Zap");   // onnx/model.onnx; pass { dtype: "q8" } for the int8 file
const results = await zap.classify(Zap.zapExtract(document, location.href));
for (const r of results) {
  console.log(r.form, r.formScore);
  for (const f of r.fields) console.log(f.element, f.label, f.score);   // f.element is the <input>/<select>/<textarea>
}

Parity checks: these run on 400 random real pages.

  • Extraction: the JS extractor (DOM via jsdom) and the Python extractor produce identical model input for all 821 forms the JS side finds. The Python side finds 4 extra forms on 2 malformed spam pages. The Python side re-parses pages with an HTML5-spec parser so its tree matches a browser's.
  • End to end: JS (transformers.js + ONNX) and Python (PyTorch) predict identical labels for 818/818 forms and 1,413/1,413 fields.

Speed: measured on an Apple M4 Max CPU with onnxruntime via transformers.js, model only.

  • 5.3 ms per form with fp32 ONNX, and 3.1 ms with int8 ONNX.
  • The int8 file agrees with fp32 on 99.91% of predictions, and bf16 PyTorch on 99.99%.

How the output is decoded: each field is read at its [FLD] token, restricted to the field:* labels. The form purpose is read at [CLS], restricted to the form:* labels. A form longer than 512 tokens is split into windows that each repeat the form context, and the [CLS] logits are averaged across windows.

Labels

field label meaning
username account identifier that is not strictly an email: username, user ID, "email or username", "email or phone"
email an email address (login, signup, newsletter, checkout, …)
current-password existing password / sign-in PIN (login, "current password")
new-password password being created or set, including "confirm password"
one-time-code 2FA / OTP / SMS / email verification code, including each box of split code inputs
tel phone number or part of one
name, given-name, family-name, organization person / company names
street-address, address-line2, address-level2 (city), address-level1 (state/region), postal-code, country address parts
cc-name, cc-number, cc-exp, cc-exp-month, cc-exp-year, cc-csc payment card
bday date of birth (or part of it)
search, captcha search box, CAPTCHA / anti-spam answer
other everything else: messages, coupons, quantities, honeypots…

Label names follow the HTML autocomplete tokens where one exists. username vs email is decided by what the field accepts: an email-only login box is email. On a login form, fill the saved login identifier into either one.

Evaluation

Splits are made by website host: 4% test, 2% val. Every deduplicated form signature belongs to exactly one split. Headline numbers use only real web forms. Synthetic forms (see below) are scored separately.

Per class, real held-out test (bf16 weights; F1, with n in parentheses):

field F1 (n) field F1 (n)
email 0.990 (3193) postal-code 0.970 (322)
current-password 0.991 (1838) captcha 0.953 (301)
new-password 0.987 (712) address-level2 0.897 (292)
username 0.976 (1284) organization 0.947 (256)
tel 0.988 (1337) street-address 0.958 (208)
name 0.962 (1461) address-level1 0.891 (175)
given-name 0.943 (603) country 0.913 (158)
family-name 0.982 (506) one-time-code 0.929 (135)
search 0.963 (948) bday 0.932 (53)
other 0.968 (5955) address-line2 0.847 (48)
form F1 (n) form F1 (n)
login 0.992 (1836) password-change 0.943 (50)
contact 0.963 (1640) otp 0.897 (40)
search 0.954 (1043) password-recovery 0.933 (38)
newsletter 0.965 (667) address 0.783 (35)
signup 0.962 (417) payment 0.667 (8)
other 0.893 (1064)

Real payment-card forms are very rare in a static crawl: the real test has 8 payment forms and 6 card fields. On the synthetic held-out test (84 forms), the card fields cc-number, cc-name, cc-exp, cc-exp-month, cc-exp-year and cc-csc score F1 = 1.00, payment forms 0.98 and otp forms 1.00. Synthetic forms are easier than real ones, so treat these as a sanity check, not a real-world number.

Most common confusions: given-name↔name (a lone "Name" field sitting next to "Last name"), search↔other (site filters), username↔email ("Login" boxes that probably take an email), and address-level1/2↔other.

Label quality: a second, independent reviewer model re-labeled a stratified sample of 80 teacher-labeled test forms (217 fields) straight from the HTML. It found 1 clear field error out of 210 checkable fields (0.5%) and 1 clear form-purpose error in 80 (1.3%), plus 6 ambiguous cases of each kind. The one systematic teacher weakness it found is honeypot fields that carry realistic labels.

The full reports are in eval/, including per-class precision/recall and confusions for bf16 and fp32 weights and the heuristic baseline.

How it was made

Data. 62 random WARC files from Common Crawl CC-MAIN-2026-39 (September 2026) were streamed in full, one request per file. That's 998,946 pages with at least one fillable field, 2.30M forms, and 948,742 unique forms after deduplicating by form signature. Pages are re-serialized through an HTML5-spec parser (lexbor) before extraction so the tree matches what a browser builds. Fields outside any <form> are grouped the way a single-page app lays them out (nearest ancestor with a button).

Labels. A private, high-batch Qwen3.8-Flash-Next (NVFP4, SGLang, thinking off, greedy decoding) ran inside the training job. It labeled every field and the form purpose for 170,163 unique forms in three rounds:

  1. A priority-weighted pass: password forms first, then multi-field forms, then single-field ones.
  2. A targeted pass over not-yet-labeled real forms whose fields look like address, OTP or payment fields.
  3. The synthetic top-up described below.

The teacher saw the cleaned form HTML with numbered fields, the page title and the URL, plus a label spec with rules for the hard cases: current vs new password, email vs username, honeypots, and autocomplete values set only to block autofill.

  • Calibration: on 222 forms where site developers set standard autocomplete tokens, the teacher (with autocomplete hidden) agreed with the developer token on 84% of fields. Manual inspection showed almost every disagreement was the site being wrong, e.g. Gravity Forms honeypots tagged autocomplete="new-password", or "First Name" fields tagged name.
  • Synthetic top-up: static crawls rarely contain card-payment, OTP and password-change pages. The teacher therefore also wrote 2,578 synthetic forms in 23 languages and 12 markup styles, from React div-soup with no <form> to split card fields, with deliberately wrong or missing autocomplete. They go through the same extractor and the same labeling pass, and make up about 1% of training forms.

Model. BertForTokenClassification: 8 layers, hidden size 384, 6 heads, FFN 1536, 16k WordPiece vocabulary trained on serialized forms, 512 positions. A single token-classification head predicts form:* at [CLS] and field:* at every [FLD] marker, so the whole form is classified in one pass and every field sees its neighbours. That is how Zap tells new-password from current-password, and a signup email from a login username.

Training. Done on one RTX PRO 6000 (Blackwell) with bf16 autocast:

  1. MLM pretraining on 892k unlabeled train-split forms (108M tokens, 3 epochs, about 8 minutes).
  2. Fine-tuning on 217,782 labeled rows, with rare-class forms oversampled ×3 and label smoothing 0.05. Hint dropout randomly removes autocomplete (25%), labels, names and placeholders, and flips 8% of password inputs to type=text to mimic "show password" toggles.
  3. The best of 6 epochs was picked on validation.

The published weights are bf16. The whole project took about 6 hours of workstation time: harvesting, three rounds of teacher labeling, synthesis, two training runs, evaluation and export.

Limitations

  • Zap reads the DOM text and attributes, not pixels or CSS. In a browser, drop invisible inputs (honeypots) before classifying, e.g. with offsetParent === null or getComputedStyle. The training crawl only saw static HTML, where some CSS-hidden honeypots remained, so Zap usually labels those other, but not always.
  • The training pages come from a static crawl, so heavily scripted flows (identifier-first sign-in, checkout iframes) are covered mainly by synthetic examples. Real-world accuracy on payment forms is not well measured (8 test forms).
  • The labels come from an LLM teacher. It is audited, but it isn't human annotation, so expect a small amount of label noise, especially on ambiguous name/given-name and search/other cases.
  • Fields inside cross-origin iframes (e.g. hosted card fields) are invisible to any content script, including zap-extract.js.

Files

file what
model.safetensors bf16 weights (BertForTokenClassification, 20.7M params)
config.json, tokenizer.json, tokenizer_config.json config with the 37-label map; 16k WordPiece tokenizer
onnx/model.onnx, onnx/model_quantized.onnx fp32 and int8 ONNX for onnxruntime / transformers.js
zap_extract.py / zap-extract.js form extractor (Python canonical / DOM port)
zap_infer.py / zap-infer.js windowing + inference helpers
eval/ evaluation reports, export parity, dataset statistics
training/ the code used to harvest, label, synthesize, train and export

License

MIT

Downloads last month
-
Safetensors
Model size
20.7M params
Tensor type
BF16
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support