Loading…
Loading…
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).
username, email, current-password, new-password, one-time-code, tel, names, address parts, payment-card parts, bday, search, captcha, otherlogin, signup, password-change, password-recovery, otp, payment, address, newsletter, contact, search, otherSo 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.
Live websites: 147 real login, sign-up and password-reset pages rendered in Chromium. Every visible field was labeled blind from a marked screenshot, and Zap's predictions were compared with the same keyword heuristic (details):
# 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.
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.
Speed: measured on an Apple M4 Max CPU with onnxruntime via transformers.js, model only.
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.
| 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.
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) |
|---|---|---|---|
| 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.
This test checks Zap outside the static crawl, on the pages a password manager actually meets.
Setup:
zap-extract.js ran in every frame, as an extension content script with all_frames would. Nothing was typed or submitted.| Zap | keyword heuristic¹ | |
|---|---|---|
| field accuracy | 96.5% (251/260) | 72.3% (188/260) |
| form-purpose accuracy | 91.2% (166/182) | 54.4% (99/182) |
| login-form F1 (96 login forms) | 0.960 | 0.610 |
| pages with every field and form right | 90.5% (133/147) | 29.9% (44/147) |
| current vs new password (56 password fields) | 96.4% | 91.1% |
| password-field detection F1 | 1.000 | 1.000 |
login-identifier (username/email) F1 |
0.993 | 0.977 |
Both systems find every password box. The difference is everything around it:
newsletter or other. Zap finds all 96, at precision 0.923.login (Coinbase, Dropbox, Proton) or newsletter (Notion, Netflix).zap-extract.js returned 267 of 282 visible text-like inputs.aria-hidden="true", which the extractor skips by design. They are pre-rendered second-step or password-manager-only fields.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:
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.
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.<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:
autocomplete (25%), labels, names and placeholders, and flips 8% of password inputs to type=text to mimic "show password" toggles.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.
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.aria-hidden inputs.name/given-name and search/other cases.all_frames: true, and they are classified without the parent page's form context.| 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 |
assets/ |
the live-site benchmark chart |
training/ |
the code used to harvest, label, synthesize, train and export |
MIT