/**
* Login-field detection: pure scoring logic over "input descriptors".
*
* A descriptor is a plain object produced in content.js:
* {
* index, // scan order among all inputs
* el, // the actual <input> (DOM object; only structure is used)
* type, name, id, placeholder, autocomplete, ariaLabel, labelText,
* visible, disabled, readonly,
* formEl // owning <form> or null
* }
*
* Everything here is string/number logic — no DOM API access beyond `contains`
* — so tools/test-field-detect.js can exercise it with plain node.
*
* The haystack (name/id/placeholder/aria-label/label text) is normalized so
* that any non-alphanumeric run becomes "_": "E-mail address" -> "e_mail",
* "User Name" -> "user_name". Keywords then match on underscore boundaries.
*/
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) {
module.exports = api;
} else {
root.GnCredsDetect = api;
}
})(typeof self !== "undefined" ? self : globalThis, function () {
"use strict";
// Input types allowed as username carriers.
const USERNAME_TYPES = new Set(["text", "", "email", "tel"]);
// Input types allowed as password carriers (besides type="password").
const PASSWORD_TEXT_TYPES = new Set(["text", "", "tel"]);
// [keyword, weight] — best hits give up to ~6; forms usually label the
// field 2-3 ways at once (name + label + placeholder), so one strong word
// is enough to beat noise.
const USERNAME_KEYWORDS = [
["username", 6], ["user_name", 6], ["user_id", 4], ["userid", 4],
["login", 5], ["log_in", 3], ["logon", 3], ["signin", 2],
["email", 5], ["e_mail", 5], ["mail", 3], ["correo", 4], ["courriel", 4],
["phone", 4], ["tel", 2], ["telefon", 4], ["mobile", 2],
["account", 2], ["nick", 3], ["handle", 2], ["member", 2],
["benutzer", 5], ["benutzername", 6], ["kennung", 3],
["логин", 6], ["пользователь", 5], ["юзер", 4], ["почта", 5], ["телефон", 4],
["usuario", 5], ["utilisateur", 4],
];
const PASSWORD_KEYWORDS = [
["password", 8], ["passwd", 8], ["pwd", 8], ["pass", 7], ["pw", 5],
["passwort", 8], ["motdepasse", 8], ["mot_de_passe", 8],
["contrasena", 8], ["contrasenia", 8],["senha", 8], ["haslo", 6],
["geslo", 6], ["пароль", 8], ["пассворд", 6],
];
// Word is present but the field is almost certainly not a login field.
// -8 per hit beats one medium keyword (+8/password or +6/username).
// The confirm/repeat group exists so that on a signup form the *real*
// password wins over "confirm_password": we only autofill existing
// accounts, so a repeat field should never become the fill target.
const ANTI_KEYWORDS = [
"cc", "card", "cvc", "cvv", "security_code", "search", "query", "filter",
"coupon", "promo", "newsletter", "subscribe", "subject", "message",
"comment", "first_name", "lastname", "last_name", "fname", "lname",
"birth", "dob", "zip", "postal", "amount", "price", "total", "qty",
"country", "city", "street", "address",
"otp", "one_time", "onetime", "one_time_code", "verification",
"confirm", "repeat", "retype", "again",
];
// Autocomplete attribute (normalized), exact token: [token, usernameKindWeight, passwordKindWeight]
const AUTOCOMPLETE_TOKENS = {
username: { username: 9, password: 0 },
current_password: { username: 0, password: 9 },
new_password: { username: 0, password: 7 },
email: { username: 7, password: 0 },
tel: { username: 3, password: 0 },
};
function normalize(text) {
return String(text == null ? "" : text)
.toLowerCase()
.replace(/[^a-zа-яё0-9]+/g, "_");
}
// "(^|_)" — keyword boundaries survive normalization because separators
// became underscores and tokens keep their own letters.
function keywordRegex(keyword) {
return new RegExp("(?:^|_)" + keyword + "(?:_|$)");
}
// Sum of the two strongest distinct keyword hits in the field's haystack.
function keywordScore(haystack, keywords) {
const hits = [];
for (const [keyword, weight] of keywords) {
if (keywordRegex(keyword).test(haystack)) hits.push(weight);
}
hits.sort((a, b) => b - a);
return (hits[0] || 0) + (hits[1] || 0) * 0.5;
}
function autocompletePenalty(ac) {
// one-time codes and card fields are never login credentials
if (ac === "one_time_code" || ac.startsWith("cc_")) return -1000;
return 0;
}
function autocompleteBonus(ac, kind) {
const token = AUTOCOMPLETE_TOKENS[ac];
return token ? token[kind] : 0;
}
function scoreUsername(field) {
let score = 0;
switch (field.type) {
case "email": score += 5; break;
case "tel": score += 2; break;
case "text": case "": break;
case "search": score -= 4; break;
default: return -1000;
}
const ac = normalize(field.autocomplete);
const penalty = autocompletePenalty(ac);
if (penalty < 0) return penalty;
score += autocompleteBonus(ac, "username");
score += keywordScore(normalize(
`${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}`
), USERNAME_KEYWORDS);
for (const anti of ANTI_KEYWORDS) {
if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8;
// placeholder/labels mention search and coupons even on login pages,
// so anti-words only count in name/id
}
if (!field.visible) score -= 40;
if (field.disabled) score -= 100;
if (field.readonly) score -= 15;
return score;
}
function scorePassword(field) {
let score = 0;
if (field.type === "password") score += 12;
else if (PASSWORD_TEXT_TYPES.has(field.type)) { /* text/tel with a password hint */ }
else return -1000;
const ac = normalize(field.autocomplete);
const penalty = autocompletePenalty(ac);
if (penalty < 0) return penalty;
score += autocompleteBonus(ac, "password");
score += keywordScore(normalize(
`${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}`
), PASSWORD_KEYWORDS);
for (const anti of ANTI_KEYWORDS) {
if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8;
}
if (!field.visible) score -= 50;
if (field.disabled) score -= 100;
if (field.readonly) score -= 15;
return score;
}
// Passwords qualify unconditionally on type="password" (the strongest
// signal there is) or, for text/tel fields, on a single strong keyword
// (e.g. type="text" with name="pass" — JS-revealed password inputs).
function isPasswordCandidate(field) {
if (field.type === "password") return scorePassword(field) > -1000;
if (!PASSWORD_TEXT_TYPES.has(field.type)) return false;
return scorePassword(field) >= 7;
}
// The username is searched inside the form; for SPA inputs without a
// <form>, walk up to three ancestor levels before giving up.
function findContextFor(field, fields) {
if (field.formEl) return field.formEl;
let current = field.el.parentElement;
for (let depth = 0; current && depth < 3; depth++) {
const hasUsername = fields.some(
(f) => f !== field && current.contains(f.el) && scoreUsername(f) > 0
);
if (hasUsername) return current;
current = current.parentElement;
}
return null;
}
function pickUsername(candidates, password) {
const eligible = candidates.filter(
(f) =>
f !== password &&
f.usernameScore > 0 &&
USERNAME_TYPES.has(f.type)
);
if (eligible.length) {
// strongest signal wins; ties break toward DOM order (above password)
return eligible.sort(
(a, b) => b.usernameScore - a.usernameScore || a.index - b.index
)[0];
}
// Form has no identifying hints: fall back to the closest text-like
// visible input preceding the password field.
const fallback = candidates
.filter(
(f) =>
f !== password &&
USERNAME_TYPES.has(f.type) &&
f.visible &&
!f.disabled &&
f.index < password.index
)
.sort((a, b) => b.index - a.index)[0];
return fallback || null;
}
function findLoginTargets(fields) {
const scored = fields.map((field) => ({
...field,
usernameScore: scoreUsername(field),
passwordScore: scorePassword(field),
}));
const candidates = scored
.filter((f) => isPasswordCandidate(f))
.sort((a, b) => b.passwordScore - a.passwordScore || a.index - b.index);
const usedContexts = new Set(); // one card per form
const usedUsernames = new Set();
const targets = [];
for (const password of candidates) {
const context = findContextFor(password, scored);
const contextKey = context || password.el;
if (usedContexts.has(contextKey)) continue; // merged into the first password of the form
usedContexts.add(contextKey);
const inside = context ? scored.filter((f) => context.contains(f.el)) : [password];
const username = pickUsername(inside.filter((f) => !usedUsernames.has(f.el)), password);
if (username) usedUsernames.add(username.el);
targets.push({ password, username: username || null, context: context || null });
}
// DOM order for stable card stacking
return targets.sort((a, b) => a.password.index - b.password.index);
}
return {
scoreUsername,
scorePassword,
isPasswordCandidate,
findContextFor,
pickUsername,
findLoginTargets,
};
});