1import type {2 FieldPrediction,3 MLFieldFeatures,4} from "../shared/ml";56export type { FieldPrediction, MLFieldFeatures } from "../shared/ml";78export class ContentMLDetector {9 private static instance: ContentMLDetector;10 private initialized = false;1112 static getInstance(): ContentMLDetector {13 if (!ContentMLDetector.instance) {14 ContentMLDetector.instance = new ContentMLDetector();15 }16 return ContentMLDetector.instance;17 }1819 async init(): Promise<void> {20 if (this.initialized) return;21 const response = await chrome.runtime.sendMessage({ type: "ML_INIT" });22 this.initialized = Boolean((response as { ok?: boolean }).ok);23 }2425 extractFeatures(input: HTMLInputElement): MLFieldFeatures {26 const inputType = input.type.toLowerCase();27 const matchScore = (text: string, patterns: RegExp[]): number => {28 if (!text) return 0;29 const matches = patterns.reduce(30 (count, pattern) => count + (pattern.test(text) ? 1 : 0),31 032 );33 return Math.min((matches / patterns.length) * 3, 1);34 };3536 const userPatterns = [37 /user/i,38 /login/i,39 /usr/i,40 /uname/i,41 /account/i,42 /acct/i,43 /signin/i,44 /sign-in/i,45 /session/i,46 /member/i,47 /alias/i,48 ];49 const emailPatterns = [/email/i, /e-mail/i, /mail/i];50 const passPatterns = [51 /pass/i,52 /password/i,53 /pwd/i,54 /secret/i,55 /passphrase/i,56 /key/i,57 /credential/i,58 ];5960 const features: MLFieldFeatures = {61 type_text: inputType === "text" ? 1 : 0,62 type_email: inputType === "email" ? 1 : 0,63 type_password: inputType === "password" ? 1 : 0,64 type_tel: inputType === "tel" ? 1 : 0,65 type_number: inputType === "number" ? 1 : 0,66 type_search: inputType === "search" ? 1 : 0,67 type_url: inputType === "url" ? 1 : 0,68 type_other: [69 "text",70 "email",71 "password",72 "tel",73 "number",74 "search",75 "url",76 ].includes(inputType)77 ? 078 : 1,79 auto_username: 0,80 auto_email: 0,81 auto_current_password: 0,82 auto_new_password: 0,83 auto_one_time_code: 0,84 auto_off: 0,85 auto_other: 0,86 name_has_user: matchScore(input.name, userPatterns),87 name_has_login: matchScore(input.name, [/login/i]),88 name_has_email: matchScore(input.name, emailPatterns),89 name_has_pass: matchScore(input.name, passPatterns),90 name_length: input.name.length / 50,91 id_has_user: matchScore(input.id, userPatterns),92 id_has_login: matchScore(input.id, [/login/i]),93 id_has_email: matchScore(input.id, emailPatterns),94 id_has_pass: matchScore(input.id, passPatterns),95 id_length: input.id.length / 50,96 placeholder_has_user: matchScore(input.placeholder, userPatterns),97 placeholder_has_email: matchScore(input.placeholder, emailPatterns),98 placeholder_has_pass: matchScore(input.placeholder, passPatterns),99 placeholder_length: input.placeholder.length / 100,100 aria_label_has_user: matchScore(101 input.getAttribute("aria-label") || "",102 userPatterns103 ),104 aria_label_has_email: matchScore(105 input.getAttribute("aria-label") || "",106 emailPatterns107 ),108 aria_label_has_pass: matchScore(109 input.getAttribute("aria-label") || "",110 passPatterns111 ),112 aria_label_length: (input.getAttribute("aria-label") || "").length / 100,113 parent_is_form: 0,114 parent_is_div: 0,115 parent_is_section: 0,116 sibling_count: 0,117 has_password_sibling: 0,118 has_email_sibling: 0,119 form_has_submit: 0,120 form_action_has_login: 0,121 is_required: input.required ? 1 : 0,122 has_placeholder: input.placeholder ? 1 : 0,123 has_aria_label: input.getAttribute("aria-label") ? 1 : 0,124 inputmode_numeric: input.inputMode === "numeric" ? 1 : 0,125 };126127 const auto = (input.getAttribute("autocomplete") || "").toLowerCase();128 if (auto.includes("username")) features.auto_username = 1;129 else if (auto.includes("email")) features.auto_email = 1;130 else if (auto.includes("current-password")) features.auto_current_password = 1;131 else if (auto.includes("new-password")) features.auto_new_password = 1;132 else if (auto.includes("one-time-code")) features.auto_one_time_code = 1;133 else if (auto === "off") features.auto_off = 1;134 else features.auto_other = 1;135136 const parent = input.parentElement;137 if (parent) {138 const parentName = parent.tagName.toLowerCase();139 features.parent_is_form = parentName === "form" ? 1 : 0;140 features.parent_is_div = parentName === "div" ? 1 : 0;141 features.parent_is_section = parentName === "section" ? 1 : 0;142143 const siblings = Array.from(parent.children).filter(144 (el) => el.tagName === "INPUT"145 );146 features.sibling_count = siblings.length / 10;147 for (const sibling of siblings) {148 if (sibling === input) continue;149 const siblingType = (sibling as HTMLInputElement).type;150 if (siblingType === "password") features.has_password_sibling = 1;151 if (siblingType === "email") features.has_email_sibling = 1;152 }153 }154155 const form = input.closest("form");156 if (form) {157 features.form_has_submit = form.querySelector(158 'button[type="submit"], input[type="submit"]'159 )160 ? 1161 : 0;162 const action = form.getAttribute("action") || "";163 features.form_action_has_login = /login|signin|auth|session/i.test(action)164 ? 1165 : 0;166 }167168 return features;169 }170171 async predictBatch(172 inputs: HTMLInputElement[]173 ): Promise<(FieldPrediction | null)[]> {174 try {175 const featuresArray = inputs.map((input) => this.extractFeatures(input));176 const response = await chrome.runtime.sendMessage({177 type: "ML_PREDICT_BATCH",178 payload: { featuresArray },179 });180 const data = (response as { ok?: boolean; data?: unknown }).data;181 return (response as { ok?: boolean }).ok && Array.isArray(data)182 ? (data as FieldPrediction[])183 : inputs.map(() => null);184 } catch {185 return inputs.map(() => null);186 }187 }188}189190export const contentMLDetector = ContentMLDetector.getInstance();