content-ml-detector.ts

6.1 KB · raw

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();