Zap / zap-extract.js
ProCreations's picture
Zap v1: 20.7M-param web form & field classifier for autofill (bf16, MIT)
e4805d0 verified
Raw History Blame Contribute Delete
13.4 kB
// Zap form extractor — JavaScript port of zap/extract.py (keep the two in lockstep).
// Runs on a live DOM (browser extension content script) or jsdom.
// const forms = zapExtract(document, location.href);
// forms[i] = { root, elements, ctx, fields, text } // `text` is what the Zap model reads
(function (global) {
"use strict";
const SKIP_INPUT_TYPES = new Set(["hidden", "submit", "button", "reset", "image", "file", "checkbox", "radio", "range", "color"]);
const FIELD_TAGS = new Set(["input", "select", "textarea"]);
const NO_TEXT_TAGS = new Set(["script", "style", "noscript", "template", "svg", "select", "textarea", "option", "head"]);
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "legend"]);
const DATA_ATTRS = ["data-testid", "data-test", "data-qa", "data-cy", "formcontrolname", "data-field", "data-name", "data-type"];
const MAX_FIELDS = 40;
const MAX_FORMS = 10;
const CTX_LIMITS = { title: 60, url: 60, form: 60, head: 60, btn: 60, link: 80, txt: 120, hid: 40 };
const FIELD_LIMITS = { name: 40, id: 40, class: 30, ac: 30, ph: 50, lbl: 60, aria: 50, tip: 40, near: 50, after: 30, mode: 12, max: 6, opt: 60, data: 30 };
const CTX_ORDER = [["title", "[TITLE]"], ["url", "[URL]"], ["form", "[FORM]"], ["head", "[HEAD]"], ["btn", "[BTN]"], ["link", "[LINK]"], ["txt", "[TXT]"], ["hid", "[HID]"]];
const FIELD_ORDER = [["type", "[TYPE]"], ["name", "[NAME]"], ["id", "[ID]"], ["class", "[CLASS]"], ["ac", "[AC]"], ["ph", "[PH]"], ["lbl", "[LBL]"], ["aria", "[ARIA]"], ["tip", "[TIP]"], ["near", "[NEAR]"], ["after", "[AFTER]"], ["mode", "[MODE]"], ["max", "[MAX]"], ["req", "[REQ]"], ["opt", "[OPT]"], ["data", "[DATA]"]];
const ELEMENT = 1, TEXT = 3, CDATA = 4;
function norm(s) {
if (!s) return "";
return s.normalize("NFKC").replace(/\s+/g, " ").trim();
}
function cut(s, n) {
const cps = Array.from(s);
return cps.length <= n ? s.trimEnd() : cps.slice(0, n).join("").trimEnd();
}
function words(s) {
if (!s) return "";
s = s.normalize("NFKC");
s = s.replace(/([a-z])([A-Z])/g, "$1 $2");
s = s.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
s = s.replace(/([A-Za-z])([0-9])/g, "$1 $2");
s = s.replace(/([0-9])([A-Za-z])/g, "$1 $2");
s = s.replace(/[^\p{L}\p{N}\p{M}]/gu, " ");
return s.replace(/\s+/g, " ").trim().toLowerCase();
}
function tag(el) { return el && el.nodeType === ELEMENT ? el.localName.toLowerCase() : ""; }
function attr(el, name) { const v = el.getAttribute(name); return v === null ? "" : v; }
function has(el, name) { return el.hasAttribute(name); }
function unquote(p) { try { return decodeURIComponent(p); } catch (e) { return p; } }
function text(el) {
const parts = [];
(function walk(e) {
if (NO_TEXT_TAGS.has(tag(e))) return;
for (let c = e.firstChild; c; c = c.nextSibling) {
if (c.nodeType === ELEMENT) walk(c);
else if (c.nodeType === TEXT || c.nodeType === CDATA) parts.push(c.data);
}
})(el);
return norm(parts.join(" "));
}
function inputType(el) {
const t = tag(el);
if (t === "input") return attr(el, "type").trim().toLowerCase() || "text";
return t;
}
function hiddenAttr(el) {
if (has(el, "hidden") || attr(el, "aria-hidden").trim().toLowerCase() === "true") return true;
const style = attr(el, "style").replace(/ /g, "").toLowerCase();
return style.includes("display:none") || style.includes("visibility:hidden");
}
function isField(el) {
const t = tag(el);
if (!FIELD_TAGS.has(t)) return false;
if (t === "input" && SKIP_INPUT_TYPES.has(inputType(el))) return false;
return !has(el, "disabled");
}
function visibleWithin(el, root) {
for (let n = el; n; n = n.parentElement) {
if (hiddenAttr(n)) return false;
if (n === root) return true;
}
return true;
}
function containsField(el) {
return FIELD_TAGS.has(tag(el)) || !!el.querySelector("input,select,textarea");
}
// Previous / next siblings in DOM order, nearest first: ["t", string] or ["e", element]
function* prevNodes(el) {
for (let n = el.previousSibling; n; n = n.previousSibling) {
if (n.nodeType === TEXT || n.nodeType === CDATA) yield ["t", n.data];
else if (n.nodeType === ELEMENT) yield ["e", n];
}
}
function* nextNodes(el) {
for (let n = el.nextSibling; n; n = n.nextSibling) {
if (n.nodeType === TEXT || n.nodeType === CDATA) yield ["t", n.data];
else if (n.nodeType === ELEMENT) yield ["e", n];
}
}
function nearText(el, root) {
let node = el;
for (let level = 0; level < 3; level++) {
let seen = 0;
for (const [kind, v] of prevNodes(node)) {
let t;
if (kind === "t") t = norm(v);
else { if (containsField(v)) return ""; t = text(v); }
if (t) return t;
if (kind === "e") { seen++; if (seen >= 3) break; }
}
if (node === root) break;
node = node.parentElement;
if (!node || node === root) break;
}
return "";
}
function afterText(el) {
let seen = 0;
for (const [kind, v] of nextNodes(el)) {
let t;
if (kind === "t") t = norm(v);
else { if (containsField(v)) return ""; t = text(v); }
if (t) return t;
if (kind === "e") { seen++; if (seen >= 2) break; }
}
return "";
}
function makePage(doc, url) {
const byId = new Map(), labelFor = new Map();
for (const e of doc.getElementsByTagName("*")) {
const i = e.getAttribute("id");
if (i && !byId.has(i)) byId.set(i, e);
if (tag(e) === "label" && e.getAttribute("for")) {
const f = e.getAttribute("for");
if (!labelFor.has(f)) labelFor.set(f, []);
labelFor.get(f).push(e);
}
}
const t = doc.querySelector("title");
return { doc, url, byId, labelFor, title: t ? norm(t.textContent) : "" };
}
function labelText(el, page) {
const out = [];
for (const ref of attr(el, "aria-labelledby").split(/\s+/).filter(Boolean)) {
const e = page.byId.get(ref);
if (e) out.push(text(e));
}
const i = el.getAttribute("id");
if (i) for (const lab of page.labelFor.get(i) || []) out.push(text(lab));
for (let n = el.parentElement; n; n = n.parentElement) {
if (tag(n) === "label") { out.push(text(n)); break; }
}
const uniq = [];
for (const t of out) if (t && !uniq.includes(t)) uniq.push(t);
return uniq.join(" | ");
}
function isButtonish(e) {
const t = tag(e);
if (t === "button") return true;
if (t === "input" && ["submit", "button", "image"].includes(inputType(e))) return true;
return attr(e, "role").toLowerCase() === "button";
}
function isHeading(e) { return HEADING_TAGS.has(tag(e)) || attr(e, "role").toLowerCase() === "heading"; }
function* iterSelf(root) { yield root; yield* root.getElementsByTagName("*"); }
function heading(root) {
for (const e of iterSelf(root)) {
if (isHeading(e)) { const t = text(e); if (t) return t; }
}
let node = root;
for (let level = 0; level < 4; level++) {
let n = 0;
for (const [kind, sib] of prevNodes(node)) {
if (kind !== "e") continue;
n++;
if (n > 5) break;
if (isHeading(sib)) { const t = text(sib); if (t) return t; }
const hs = Array.from(sib.getElementsByTagName("*")).filter(isHeading);
for (let k = hs.length - 1; k >= 0; k--) { const t = text(hs[k]); if (t) return t; }
}
node = node.parentElement;
if (!node || tag(node) === "body" || tag(node) === "html") break;
}
return "";
}
function buttonTexts(root) {
const out = [];
for (const e of iterSelf(root)) {
if (!isButtonish(e)) continue;
const t = text(e) || norm(attr(e, "value")) || norm(attr(e, "aria-label")) || norm(attr(e, "alt")) || norm(attr(e, "title"));
if (t && !out.includes(t)) out.push(cut(t, 30));
if (out.length >= 3) break;
}
return out.join(" | ");
}
function linkTexts(root) {
const out = [];
for (const e of iterSelf(root)) {
if (tag(e) !== "a") continue;
const t = text(e) || norm(attr(e, "aria-label")) || norm(attr(e, "title"));
if (t && !out.includes(t)) out.push(cut(t, 30));
if (out.length >= 4) break;
}
return out.join(" | ");
}
function hiddenNames(root) {
const out = [];
for (const e of iterSelf(root)) {
if (tag(e) === "input" && inputType(e) === "hidden") {
const w = words(attr(e, "name"));
if (w && !out.includes(w)) out.push(w);
if (out.length >= 4) break;
}
}
return out.join(" ");
}
function urlPath(u, base) {
try { const x = base ? new URL(u, base) : new URL(u); return { host: x.hostname, path: x.pathname }; } catch (e) { return null; }
}
function urlWords(url) {
const u = urlPath(url);
if (!u) return "";
const host = u.host.split(".");
const lead = host.length >= 3 && host[0] !== "www" ? host[0] : "";
return words(lead + " " + unquote(u.path));
}
function formWords(root) {
const parts = [attr(root, "id"), attr(root, "name"), attr(root, "class")];
if (tag(root) === "form") {
const a = attr(root, "action");
const u = a ? urlPath(a, "http://x/") : null;
parts.push(u ? unquote(u.path) : "");
} else parts.push(attr(root, "role"));
return (words(parts.join(" ")) + " " + norm(attr(root, "aria-label")).toLowerCase()).trim();
}
function fieldFeatures(el, root, page) {
const t = tag(el);
const f = { type: t !== "input" ? t : "input " + inputType(el) };
f.name = words(attr(el, "name"));
f.id = words(attr(el, "id"));
f.class = words(attr(el, "class"));
f.ac = norm(attr(el, "autocomplete")).toLowerCase();
f.ph = norm(attr(el, "placeholder"));
f.lbl = labelText(el, page);
f.aria = norm(attr(el, "aria-label"));
f.tip = norm(attr(el, "title"));
const near = nearText(el, root);
f.near = near === f.lbl || near === f.ph || near === f.aria ? "" : near;
f.after = afterText(el);
f.mode = norm(attr(el, "inputmode")).toLowerCase();
f.max = norm(attr(el, "maxlength"));
f.req = has(el, "required") || attr(el, "aria-required").toLowerCase() === "true";
if (t === "select") {
const opts = Array.from(el.getElementsByTagName("option")).map((o) => norm(o.textContent)).filter(Boolean);
f.opt = opts.length ? opts.length + ": " + opts.slice(0, 6).join(" | ") : "";
} else f.opt = "";
const data = DATA_ATTRS.filter((a) => attr(el, a).trim()).map((a) => norm(attr(el, a)));
f.data = words(data.slice(0, 2).join(" "));
for (const [k, n] of Object.entries(FIELD_LIMITS)) if (typeof f[k] === "string") f[k] = cut(f[k], n);
return f;
}
function formContext(root, page) {
const c = { title: page.title, url: urlWords(page.url), form: formWords(root), head: heading(root),
btn: buttonTexts(root), link: linkTexts(root), txt: text(root), hid: hiddenNames(root) };
for (const k of Object.keys(c)) c[k] = cut(c[k], CTX_LIMITS[k]);
return c;
}
function groupRoot(el, body) {
let node = el.parentElement;
for (let level = 0; level < 6; level++) {
if (!node || node === body || tag(node) === "body" || tag(node) === "html") break;
for (const d of iterSelf(node)) {
if (isButtonish(d) && !d.closest("form")) return node;
}
node = node.parentElement;
}
node = el;
for (let level = 0; level < 3; level++) {
const p = node.parentElement;
if (!p || p === body || tag(p) === "body" || tag(p) === "html") break;
node = p;
}
return node;
}
function findForms(doc) {
const body = doc.body || doc.documentElement;
const groups = new Map();
for (const el of body.getElementsByTagName("*")) {
if (!isField(el)) continue;
let root = el.parentElement ? el.parentElement.closest("form") : null;
if (!root) root = groupRoot(el, body);
if (!visibleWithin(el, root)) continue;
if (!groups.has(root)) groups.set(root, []);
const g = groups.get(root);
if (g.length < MAX_FIELDS) g.push(el);
}
return Array.from(groups.entries()).slice(0, MAX_FORMS);
}
function serializeCtx(ctx) {
return CTX_ORDER.filter(([k]) => ctx[k]).map(([k, key]) => key + " " + ctx[k]).join(" ");
}
function serializeField(f) {
const out = ["[FLD]"];
for (const [k, key] of FIELD_ORDER) {
const v = f[k];
if (k === "req") { if (v) out.push("[REQ]"); } else if (v) out.push(key + " " + v);
}
return out.join(" ");
}
function serialize(ctx, fields) {
return [serializeCtx(ctx)].concat(fields.map(serializeField)).join(" ");
}
function zapExtract(doc, url) {
const page = makePage(doc, url);
return findForms(doc).map(([root, elements]) => {
const fields = elements.map((el) => fieldFeatures(el, root, page));
const ctx = formContext(root, page);
return { kind: tag(root) === "form" ? "form" : "group", root, elements, ctx, fields, text: serialize(ctx, fields) };
});
}
const api = { zapExtract, serialize, serializeCtx, serializeField, words, norm };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else global.Zap = Object.assign(global.Zap || {}, api);
})(typeof globalThis !== "undefined" ? globalThis : this);