Form Faker

Fill any form with realistic fake data pulled from US Census name lists. Smart field detection, framework compat, persistent cache with background refresh.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Form Faker
// @namespace    https://minoa.cat
// @version      1.4.0
// @description  Fill any form with realistic fake data pulled from US Census name lists. Smart field detection, framework compat, persistent cache with background refresh.
// @author       Minoa
// @homepage     https://minoa.cat
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      raw.githubusercontent.com
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── config ───────────────────────────────────────────────────────────────────

  const NAME_URLS = {
    last:   'https://raw.githubusercontent.com/treyhunner/names/refs/heads/master/names/dist.all.last',
    male:   'https://raw.githubusercontent.com/treyhunner/names/refs/heads/master/names/dist.male.first',
    female: 'https://raw.githubusercontent.com/treyhunner/names/refs/heads/master/names/dist.female.first',
  };

  const STORAGE_KEY   = 'ff_name_cache_v1';
  const CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days before silent background refresh

  // ── static data ──────────────────────────────────────────────────────────────

  const CITIES = [
    { city: 'Austin',           state: 'Texas',          zip: '78701' },
    { city: 'Denver',           state: 'Colorado',        zip: '80203' },
    { city: 'Atlanta',          state: 'Georgia',         zip: '30303' },
    { city: 'Phoenix',          state: 'Arizona',         zip: '85003' },
    { city: 'Portland',         state: 'Oregon',          zip: '97201' },
    { city: 'Nashville',        state: 'Tennessee',       zip: '37203' },
    { city: 'Charlotte',        state: 'North Carolina',  zip: '28202' },
    { city: 'Indianapolis',     state: 'Indiana',         zip: '46204' },
    { city: 'Columbus',         state: 'Ohio',            zip: '43215' },
    { city: 'Jacksonville',     state: 'Florida',         zip: '32202' },
    { city: 'Memphis',          state: 'Tennessee',       zip: '38103' },
    { city: 'Louisville',       state: 'Kentucky',        zip: '40202' },
    { city: 'Baltimore',        state: 'Maryland',        zip: '21201' },
    { city: 'Milwaukee',        state: 'Wisconsin',       zip: '53202' },
    { city: 'Albuquerque',      state: 'New Mexico',      zip: '87102' },
    { city: 'Tucson',           state: 'Arizona',         zip: '85701' },
    { city: 'Sacramento',       state: 'California',      zip: '95814' },
    { city: 'Kansas City',      state: 'Missouri',        zip: '64101' },
    { city: 'Omaha',            state: 'Nebraska',        zip: '68102' },
    { city: 'Raleigh',          state: 'North Carolina',  zip: '27601' },
    { city: 'Colorado Springs', state: 'Colorado',        zip: '80903' },
    { city: 'Virginia Beach',   state: 'Virginia',        zip: '23451' },
    { city: 'Boise',            state: 'Idaho',           zip: '83701' },
    { city: 'Richmond',         state: 'Virginia',        zip: '23219' },
    { city: 'Spokane',          state: 'Washington',      zip: '99201' },
    { city: 'Tulsa',            state: 'Oklahoma',        zip: '74103' },
    { city: 'Tampa',            state: 'Florida',         zip: '33601' },
    { city: 'St. Louis',        state: 'Missouri',        zip: '63101' },
    { city: 'Pittsburgh',       state: 'Pennsylvania',    zip: '15201' },
    { city: 'Salt Lake City',   state: 'Utah',            zip: '84101' },
  ];

  const STREETS    = ['Oak','Maple','Cedar','Pine','Elm','Washington','Park','Lake','Hillside',
                      'Riverside','Sunset','Valley','Forest','Meadow','Spring','Church','Main',
                      'Lincoln','Jefferson','Highland','Willow','Birch','Ash','Poplar','Walnut',
                      'Lakeview','Ridge','Orchard','Clearwater'];
  const STREET_SFX = ['St','Ave','Blvd','Dr','Ln','Ct','Pl','Way','Rd','Cir','Terrace','Trail','Loop','Pass'];
  const CO_ADJ     = ['Global','Advanced','Premier','Apex','Summit','Nexus','Dynamic','Precision',
                      'Unified','Streamline','Quantum','Vanguard','Core','Peak','Delta','Horizon',
                      'Catalyst','Integral','Meridian','Axiom'];
  const CO_NOUN    = ['Solutions','Systems','Technologies','Consulting','Services','Industries',
                      'Partners','Group','Ventures','Associates','Innovations','Networks',
                      'Dynamics','Analytics','Strategies'];
  const CO_SFX     = ['Inc.','LLC','Co.','Corp.','Ltd.','& Associates','International'];
  const EMAIL_DOM  = ['gmail.com','yahoo.com','outlook.com','hotmail.com','icloud.com',
                      'protonmail.com','mail.com','live.com','me.com','fastmail.com'];
  const MONTHS     = ['January','February','March','April','May','June',
                      'July','August','September','October','November','December'];

  // ── session state ────────────────────────────────────────────────────────────

  let nameCache      = null;   // { last, male, female } in-memory for this page load
  let sessionProfile = null;   // reused within the same page load unless reset
  let fetchPromise   = null;   // in-flight fetch promise; shared by all concurrent callers

  // ── helpers ──────────────────────────────────────────────────────────────────

  const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
  const pick = arr => arr[rand(0, arr.length - 1)];

  function parseNameFile(text) {
    const out = [];
    for (const line of text.trim().split('\n')) {
      const tok = line.trim().split(/\s+/)[0];
      if (tok && /^[A-Z']+$/.test(tok))
        out.push(tok.charAt(0).toUpperCase() + tok.slice(1).toLowerCase());
    }
    return out;
  }

  // GM_xmlhttpRequest runs at extension privilege level and is never subject to
  // same-origin / CORS restrictions — the browser does not apply those checks
  // to extension-level requests.
  function fetchText(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url,
        onload:    r => r.status === 200 ? resolve(r.responseText) : reject(new Error(`HTTP ${r.status}`)),
        onerror:   () => reject(new Error('network error')),
        ontimeout: () => reject(new Error('timeout')),
        timeout: 20000,
      });
    });
  }

  // ── persistent storage ───────────────────────────────────────────────────────

  function loadStored() {
    try {
      const raw = GM_getValue(STORAGE_KEY, null);
      if (!raw) return null;
      const p = JSON.parse(raw);
      if (p && Array.isArray(p.last)   && p.last.length &&
               Array.isArray(p.male)   && p.male.length &&
               Array.isArray(p.female) && p.female.length) return p;
    } catch (_) {}
    return null;
  }

  function saveToStorage(data) {
    try { GM_setValue(STORAGE_KEY, JSON.stringify({ ...data, ts: Date.now() })); } catch (_) {}
  }

  function cacheAge() {
    try {
      const raw = GM_getValue(STORAGE_KEY, null);
      if (!raw) return Infinity;
      const p = JSON.parse(raw);
      return p && p.ts ? Date.now() - p.ts : Infinity;
    } catch (_) { return Infinity; }
  }

  // ── fetch pipeline ───────────────────────────────────────────────────────────

  /**
   * Fire the network fetch, or return the already-in-flight promise if one
   * exists. Using a shared promise (rather than a boolean flag) means a
   * second caller that arrives while the first fetch is still resolving
   * awaits the *same* result instead of getting an early no-op return — the
   * previous boolean-flag version silently did nothing for the second caller
   * even though nameCache was still empty.
   */
  function fetchAndStore(silent = false) {
    if (fetchPromise) return fetchPromise;

    fetchPromise = (async () => {
      if (!silent) toastLeft('fetching name lists...');
      try {
        const [lastTxt, maleTxt, femTxt] = await Promise.all([
          fetchText(NAME_URLS.last),
          fetchText(NAME_URLS.male),
          fetchText(NAME_URLS.female),
        ]);
        const fresh = {
          last:   parseNameFile(lastTxt),
          male:   parseNameFile(maleTxt),
          female: parseNameFile(femTxt),
        };
        nameCache = fresh;
        saveToStorage(fresh);
        if (!silent) toastLeft('name lists cached', 2500);
        else         toastLeft('name lists refreshed', 2500);
      } catch (err) {
        if (!silent) toastLeft(`fetch failed: ${err.message}`, 5000);
        else         console.warn('[FormFaker] background refresh failed:', err.message);
        throw err; // let awaiting callers see the failure
      } finally {
        fetchPromise = null;
      }
    })();

    return fetchPromise;
  }

  /**
   * Ensure nameCache is populated. Strategy (stale-while-revalidate):
   *
   *   1. already in memory             → return immediately (no I/O at all)
   *   2. stored cache present + fresh  → load instantly, skip network
   *   3. stored cache present + stale  → load instantly (fast fill), then
   *                                       silently refresh in background
   *   4. no stored cache               → block on fetch, show left toast
   *
   * fill() is called right after this resolves so cases 1-3 are always instant.
   * If two calls race in case 4, both now await the same fetchPromise instead
   * of the second one falling through with an empty nameCache.
   */
  async function ensureNames() {
    if (nameCache) return;

    const stored = loadStored();
    if (stored) {
      nameCache = stored;
      if (cacheAge() > CACHE_MAX_AGE) fetchAndStore(true); // not awaited — fire and forget
      return;
    }

    // first ever run — must block, and any concurrent caller awaits the same promise
    await fetchAndStore(false);
  }

  // ── profile generation ───────────────────────────────────────────────────────

  function makeProfile() {
    const gender    = Math.random() < 0.5 ? 'male' : 'female';
    const firstName = pick(nameCache[gender]);
    const lastName  = pick(nameCache.last);
    const loc       = pick(CITIES);
    const fn        = firstName.toLowerCase();
    const ln        = lastName.toLowerCase();

    const emailUser = pick([
      `${fn}.${ln}`,
      `${fn}${ln}`,
      `${fn[0]}${ln}`,
      `${fn}${rand(10, 99)}`,
      `${fn}.${ln.slice(0, rand(2, 4))}${rand(1, 9)}`,
    ]);
    const email = `${emailUser}@${pick(EMAIL_DOM)}`;

    const dobAge   = rand(18, 65);
    const dobYear  = new Date().getFullYear() - dobAge;
    const dobMonth = rand(1, 12);
    const dobDay   = rand(1, 28);
    const dobMM    = String(dobMonth).padStart(2, '0');
    const dobDD    = String(dobDay).padStart(2, '0');
    const dobYYYY  = String(dobYear);

    return {
      firstName, lastName,
      fullName:     `${firstName} ${lastName}`,
      email,
      emailConfirm: email,
      phone:        `(${rand(200, 999)}) ${rand(200, 999)}-${String(rand(1000, 9999))}`,
      company:      `${pick(CO_ADJ)} ${pick(CO_NOUN)} ${pick(CO_SFX)}`,
      username:     `${fn}${ln.slice(0, rand(2, 4))}${rand(10, 99)}`,
      street:       `${rand(100, 9999)} ${pick(STREETS)} ${pick(STREET_SFX)}`,
      address2:     Math.random() < 0.25 ? `Apt ${rand(1, 999)}` : '',
      city:         loc.city,
      state:        loc.state,
      zip:          loc.zip,
      country:      'United States',
      dob:          `${dobMM}/${dobDD}/${dobYear}`,
      dobIso:       `${dobYYYY}-${dobMM}-${dobDD}`,
      dobMonth:     dobMM,
      dobDay:       dobDD,
      dobYear:      dobYYYY,
      dobMonthName: MONTHS[dobMonth - 1],
      age:          String(dobAge),
      gender,
      url:          `https://${fn}${ln.slice(0, 4)}.example.com`,
    };
  }

  // ── field type detection ─────────────────────────────────────────────────────

  const SKIP_TYPES = new Set([
    'hidden','submit','button','reset','image','file',
    'checkbox','radio','range','color','password','search',
  ]);

  function getLabelText(el) {
    if (el.id) {
      try {
        const lbl = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
        if (lbl) return lbl.innerText || lbl.textContent || '';
      } catch (_) {}
    }
    const wrap = el.closest('label');
    if (wrap) return wrap.innerText || wrap.textContent || '';
    const lid = el.getAttribute('aria-labelledby');
    if (lid) {
      const lbl = document.getElementById(lid);
      if (lbl) return lbl.innerText || lbl.textContent || '';
    }
    return '';
  }

  function detectFieldType(el) {
    const tag  = el.tagName.toLowerCase();
    const type = (el.type || 'text').toLowerCase();

    if (tag === 'input' && SKIP_TYPES.has(type)) return 'skip';

    const ac = (el.getAttribute('autocomplete') || '').toLowerCase().trim();

    const rawHints = [
      ac,
      el.name || '', el.id || '',
      el.placeholder || '',
      el.getAttribute('aria-label') || '',
      getLabelText(el),
      el.getAttribute('ng-model') || '',
      el.getAttribute('data-ng-model') || '',
      el.getAttribute('v-model') || '',
      el.getAttribute('wire:model') || '',
      el.getAttribute('formcontrolname') || '',
      el.className || '',
    ].join(' ');
    const hints = rawHints.toLowerCase().replace(/[-_]/g, ' ');

    // NOTE: street-address (single-field, spec-intended for a full address,
    // sometimes a <textarea>) and address-line1 (first of a split address)
    // are distinct per the WHATWG autofill spec but are coalesced here since
    // real-world forms essentially never use both patterns on the same page.
    const AC_MAP = {
      'given-name': 'firstName', 'additional-name': 'firstName',
      'family-name': 'lastName',
      'name': 'fullName',
      'email': 'email',
      'tel': 'phone', 'tel-national': 'phone', 'tel-local': 'phone', 'tel-area-code': 'phone',
      'organization': 'company',
      'street-address': 'street', 'address-line1': 'street',
      'address-line2': 'address2',
      'address-level2': 'city',
      'address-level1': 'state',
      'postal-code': 'zip',
      'country': 'country', 'country-name': 'country',
      'username': 'username', 'nickname': 'username',
      'bday': 'dob',
      'bday-day': 'dobDay', 'bday-month': 'dobMonth', 'bday-year': 'dobYear',
      'url': 'url',
      'sex': 'gender',
    };
    if (AC_MAP[ac]) return AC_MAP[ac];

    if (type === 'email') return /confirm|verify|re.?enter|repeat|again|second|retype/.test(hints) ? 'emailConfirm' : 'email';
    if (type === 'tel')   return 'phone';
    if (type === 'date')  return 'dob';
    if (type === 'url')   return 'url';
    if (type === 'number' && /\bage\b/.test(hints)) return 'age';

    const PATTERNS = [
      [/\bfirst.?name\b|\bfname\b|\bgiven.?name\b|\bforename\b/,                     'firstName'],
      [/\blast.?name\b|\blname\b|\bsurname\b|\bfamily.?name\b/,                      'lastName'],
      [/\bfull.?name\b|\byour.?name\b|\bdisplay.?name\b|\breal.?name\b/,             'fullName'],
      [/confirm.?email|email.?confirm|re.?enter.?email|verify.?email|retype.?email/, 'emailConfirm'],
      [/\bemail\b/,                                                                    'email'],
      [/\bphone\b|\btel\b|\bmobile\b|\bcell.?phone\b/,                                'phone'],
      [/\bcompany\b|\borganization\b|\borganisation\b|\bemployer\b/,                  'company'],
      [/address.?2|addr.?2|\bapt\b|\bsuite\b|\bunit\b|\bline.?2\b/,                  'address2'],
      [/\bstreet\b|address.?1|addr.?1|\baddress\b|\baddr\b/,                          'street'],
      [/\bcity\b|\btown\b|\blocality\b/,                                               'city'],
      [/\bstate\b|\bprovince\b|\bregion\b/,                                           'state'],
      [/\bzip\b|\bpostal\b|\bpostcode\b|\bzip.?code\b/,                               'zip'],
      [/\bcountry\b/,                                                                  'country'],
      [/\busername\b|\buser.?name\b|\bhandle\b|\bscreen.?name\b/,                     'username'],
      [/\bdob\b|\bbirth.?date\b|\bdate.?of.?birth\b|\bbirthday\b/,                    'dob'],
      [/\bbirth.?month\b|\bmonth.?of.?birth\b/,                                      'dobMonth'],
      [/\bbirth.?day\b|\bday.?of.?birth\b/,                                           'dobDay'],
      [/\bbirth.?year\b|\byear.?of.?birth\b/,                                         'dobYear'],
      [/\bage\b/,                                                                      'age'],
      [/\bgender\b|\bsex\b/,                                                          'gender'],
      [/\bwebsite\b|\burl\b|\bhomepage\b/,                                            'url'],
    ];

    for (const [pat, ft] of PATTERNS) if (pat.test(hints)) return ft;
    return 'unknown';
  }

  // ── framework compat ─────────────────────────────────────────────────────────

  function setInputValue(el, value) {
    if (el.disabled || el.readOnly) return false;
    const str = String(value);
    try {
      const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
      if (desc && desc.set) desc.set.call(el, str);
      else el.value = str;
    } catch (_) { el.value = str; }
    for (const evtName of ['input', 'change', 'blur'])
      el.dispatchEvent(new Event(evtName, { bubbles: true, cancelable: true }));
    // AngularJS's ngModel directive listens for the native 'input' event above
    // and normally calls $setViewValue itself in response — this manual call
    // is defense-in-depth for older/custom directive setups where that native
    // listener path isn't wired up, not strictly required in the common case.
    try {
      if (window.angular) {
        const $el   = window.angular.element(el);
        const mdl   = $el.controller('ngModel');
        const scope = $el.scope() || $el.isolateScope();
        if (mdl && scope) scope.$apply(() => mdl.$setViewValue(str));
      }
    } catch (_) {}
    return true;
  }

  function setSelectValue(sel, value, altValues = []) {
    if (sel.disabled) return false;
    const opts = Array.from(sel.options);
    const find = needle => {
      needle = String(needle).toLowerCase();
      return opts.find(o => o.text.toLowerCase() === needle) ||
             opts.find(o => o.text.toLowerCase().startsWith(needle)) ||
             opts.find(o => o.value.toLowerCase() === needle) ||
             opts.find(o => o.text.toLowerCase().includes(needle));
    };
    let opt = find(value);
    if (!opt) for (const alt of altValues) { opt = find(alt); if (opt) break; }
    if (!opt) return false;
    sel.value = opt.value;
    sel.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
    try {
      if (window.angular) {
        const scope = window.angular.element(sel).scope();
        if (scope) scope.$apply();
      }
    } catch (_) {}
    return true;
  }

  // ── visual feedback ───────────────────────────────────────────────────────────

  function flash(el) {
    const was = { outline: el.style.outline, transition: el.style.transition, outlineOffset: el.style.outlineOffset };
    el.style.transition    = 'outline 0.1s ease, outline-offset 0.1s ease';
    el.style.outline       = '2px solid #22c55e';
    el.style.outlineOffset = '1px';
    setTimeout(() => Object.assign(el.style, was), 900);
  }

  // ── toast system ──────────────────────────────────────────────────────────────
  //
  //   toastLeft  (bottom-left)  — fetch / cache status
  //   toastRight (top-right)    — fill results
  //
  // Each toast host is a Shadow DOM root, not a plain div. A plain div's
  // inline styles usually win the specificity fight against page CSS, but not
  // always: an ancestor with `transform`, `filter`, `perspective`, or
  // `contain: layout` on <body> creates a new containing block, which makes
  // `position: fixed` resolve relative to that ancestor instead of the
  // viewport — silently breaking placement on sites that use those properties
  // for unrelated reasons. A closed styling boundary via attachShadow (mode:
  // 'open' so we can still update it, but shadow *style* encapsulation is
  // unconditional regardless of open/closed) sidesteps this: page rules can't
  // reach in, and our styles can't leak out either.

  const _toasts = {};

  function showToast(side, msg, duration = 3500) {
    if (!document.body) return;

    if (!_toasts[side]) {
      const host = document.createElement('div');
      host.id = `__ff_toast_host_${side}`;
      Object.assign(host.style, {
        position: 'fixed',
        zIndex: '2147483647',
        pointerEvents: 'none',
        ...(side === 'left'
          ? { bottom: '16px', left: '16px' }
          : { top: '16px', right: '16px' }),
      });
      document.body.appendChild(host);

      const root = host.attachShadow({ mode: 'open' });
      const bubble = document.createElement('div');
      Object.assign(bubble.style, {
        padding: '7px 12px', borderRadius: '4px',
        background: '#18181b', color: '#d4d4d8', border: '1px solid #3f3f46',
        fontSize: '11.5px', fontFamily: '"SF Mono",Menlo,Consolas,monospace',
        lineHeight: '1.6', letterSpacing: '0.02em',
        boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
        transition: 'opacity 0.18s ease, transform 0.18s ease',
        opacity: '0', transform: 'translateY(4px)',
        maxWidth: '260px', userSelect: 'none', whiteSpace: 'normal',
      });
      root.appendChild(bubble);

      _toasts[side] = { host, bubble };
    }

    const { bubble } = _toasts[side];
    bubble.textContent = `ff  ${msg}`;
    bubble.style.opacity   = '1';
    bubble.style.transform = 'translateY(0)';
    clearTimeout(bubble.__tm);
    bubble.__tm = setTimeout(() => {
      bubble.style.opacity   = '0';
      bubble.style.transform = 'translateY(4px)';
    }, duration);
  }

  const toastLeft  = (msg, dur) => showToast('left',  msg, dur);
  const toastRight = (msg, dur) => showToast('right', msg, dur);

  // ── fill ──────────────────────────────────────────────────────────────────────

  function fill(emptyOnly = false) {
    const p = sessionProfile || (sessionProfile = makeProfile());

    const VALUES = {
      firstName: p.firstName, lastName: p.lastName, fullName: p.fullName,
      email: p.email, emailConfirm: p.emailConfirm,
      phone: p.phone, company: p.company, username: p.username,
      street: p.street, address2: p.address2,
      city: p.city, state: p.state, zip: p.zip, country: p.country,
      dob: p.dob, dobIso: p.dobIso,
      dobMonth: p.dobMonth, dobDay: p.dobDay, dobYear: p.dobYear,
      dobMonthName: p.dobMonthName,
      age: p.age, gender: p.gender, url: p.url,
    };

    let filled = 0, skipped = 0;

    document.querySelectorAll('input, select, textarea').forEach(el => {
      const tag = el.tagName.toLowerCase();

      if (tag === 'select') {
        if (emptyOnly && el.selectedIndex > 0) return void skipped++;
        const ft = detectFieldType(el);
        if (ft === 'skip' || ft === 'unknown') return;
        let ok = false;
        if      (ft === 'dobMonth') ok = setSelectValue(el, p.dobMonth, [p.dobMonthName, String(parseInt(p.dobMonth, 10))]);
        else if (ft === 'state')    ok = setSelectValue(el, p.state);
        else if (ft === 'country')  ok = setSelectValue(el, p.country, ['us', 'usa', 'united states of america']);
        else if (ft === 'gender')   ok = setSelectValue(el, p.gender, [p.gender === 'male' ? 'm' : 'f']);
        else if (VALUES[ft])        ok = setSelectValue(el, VALUES[ft]);
        if (ok) { flash(el); filled++; }
        return;
      }

      if (tag === 'textarea') {
        if (emptyOnly && el.value.trim()) return void skipped++;
        const ft = detectFieldType(el);
        if (ft === 'skip' || ft === 'unknown') return;
        if (VALUES[ft] && setInputValue(el, VALUES[ft])) { flash(el); filled++; }
        return;
      }

      // <input>
      if (emptyOnly && el.value.trim()) return void skipped++;
      const ft = detectFieldType(el);
      if (ft === 'skip' || ft === 'unknown') return;
      let val = VALUES[ft];
      if (val === undefined || val === null) return;
      if (val === '' && ft === 'address2') return;

      if (el.type === 'date')                        val = p.dobIso;
      if (el.type === 'number' && ft === 'age')      val = p.age;
      if (el.type === 'number' && ft === 'dobYear')  val = p.dobYear;
      if (el.type === 'number' && ft === 'dobMonth') val = String(parseInt(p.dobMonth, 10));
      if (el.type === 'number' && ft === 'dobDay')   val = String(parseInt(p.dobDay, 10));

      if (setInputValue(el, val)) { flash(el); filled++; }
    });

    const msg = filled > 0
      ? `filled ${filled} field${filled !== 1 ? 's' : ''}${skipped ? ` (${skipped} already set)` : ''}`
      : 'no fillable fields detected';
    toastRight(msg);
  }

  // ── run ───────────────────────────────────────────────────────────────────────

  async function run(emptyOnly = false, newProfile = false) {
    try {
      if (newProfile) sessionProfile = null;
      await ensureNames();
      if (!nameCache) return;
      fill(emptyOnly);
    } catch (err) {
      toastRight(`error: ${err.message}`, 6000);
      console.error('[FormFaker]', err);
    }
  }

  // ── commands + shortcuts ──────────────────────────────────────────────────────

  GM_registerMenuCommand('Fill Empty Fields',         () => run(true,  false), 'f');
  GM_registerMenuCommand('Fill Form (Overwrite All)', () => run(false, false), 'o');
  GM_registerMenuCommand('New Profile + Fill All',    () => run(false, true),  'n');

  document.addEventListener('keydown', e => {
    if (!e.altKey || !e.shiftKey) return;
    if (e.key === 'F') { e.preventDefault(); run(true,  false); } // Alt+Shift+F
    if (e.key === 'N') { e.preventDefault(); run(false, true);  } // Alt+Shift+N
  }, true);

})();