Google Forms: AI Form Filler (Gemini)

One-click AI autofill for Google Forms using Gemini with optional one-time instruction override + quick cleaner

// ==UserScript==
// @name         Google Forms: AI Form Filler (Gemini)
// @namespace    https://www.fiverr.com/web_coder_nsd
// @version      1.1.2
// @icon         https://cdn-icons-png.flaticon.com/512/720/720311.png
// @description  One-click AI autofill for Google Forms using Gemini with optional one-time instruction override + quick cleaner
// @match        https://docs.google.com/forms/*/viewform
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
  // UI buttons
  const btn = document.createElement('button');        // Run
  const btnEdit = document.createElement('button');    // One-time prompt
  const btnClean = document.createElement('button');   // Clean all

  Object.assign(btn.style, {
    position: 'fixed', right: '16px', top: '25px', zIndex: 2147483647,
    width: '44px', height: '44px', borderRadius: '50%', border: '0',
    background: '#111', color: '#fff', fontSize: '14px', lineHeight: '44px',
    textAlign: 'center', cursor: 'pointer', boxShadow: '0 6px 16px rgba(0,0,0,.28)', userSelect: 'none'
  });
  btn.textContent = '▶';
  document.documentElement.appendChild(btn);

  Object.assign(btnEdit.style, {
    position: 'fixed', right: '9px', top: '19px', zIndex: 2147483647,
    width: '24px', height: '24px', borderRadius: '50%', border: '0',
    background: '#444', color: '#fff', fontSize: '17px', lineHeight: '14px',
    textAlign: 'center', cursor: 'pointer', boxShadow: '0 6px 16px rgba(0,0,0,.28)', userSelect: 'none'
  });
  btnEdit.title = 'Add one-time instructions';
  btnEdit.textContent = '✎';
  document.documentElement.appendChild(btnEdit);

  Object.assign(btnClean.style, {
    position: 'fixed', right: '16px', top: '72px', zIndex: 2147483647,
    width: '44px', height: '44px', borderRadius: '50%', border: '0',
    background: '#7a1d1d', color: '#fff', fontSize: '18px', lineHeight: '44px',
    textAlign: 'center', cursor: 'pointer', boxShadow: '0 6px 16px rgba(0,0,0,.28)', userSelect: 'none'
  });
  btnClean.title = 'Clear all inputs';
  btnClean.textContent = '🧹';
  document.documentElement.appendChild(btnClean);

  // one-time extra instructions, cleared after each run
  let sessionExtraInstructions = '';
  btnEdit.addEventListener('click', () => {
    const next = prompt('One-time instructions for this run only.\nExample: "email should be [email protected]"');
    if (next == null) return;
    sessionExtraInstructions = (next || '').trim();
    btnEdit.style.background = sessionExtraInstructions ? '#0a7' : '#444';
  });

  // drag all three as a group (drag using main ▶ button)
  let drag = { x: 0, y: 0, sx: 0, sy: 0, down: false };
  const onDown = (e) => {
    drag.down = true; drag.sx = e.clientX; drag.sy = e.clientY;
    const r = btn.getBoundingClientRect(); drag.x = r.left; drag.y = r.top; e.preventDefault();
  };
  const onMove = (e) => {
    if (!drag.down) return;
    const nx = drag.x + (e.clientX - drag.sx);
    const ny = drag.y + (e.clientY - drag.sy);
    [ [btn, 0], [btnClean, 47] ].forEach(([el, dy]) => {
      el.style.left = nx + 'px'; el.style.top = (ny + dy) + 'px';
      el.style.right = 'auto'; el.style.bottom = 'auto';
    });
    // the small ✎ button sits near the ▶ button
    btnEdit.style.left = (nx + 28) + 'px';
    btnEdit.style.top = (ny - 6) + 'px';
    btnEdit.style.right = 'auto';
    btnEdit.style.bottom = 'auto';
  };
  const onUp = () => { drag.down = false; };
  btn.addEventListener('mousedown', onDown);
  window.addEventListener('mousemove', onMove);
  window.addEventListener('mouseup', onUp);

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  // Gemini client
  class GeminiClient {
    constructor() {
      this.baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
      this.primaryModel = 'gemini-2.0-flash';
      this.fallbackModel = 'gemini-1.5-flash';
      this.apiKey = null;
    }
    async init() {
      if (!this.apiKey) {
        let k = localStorage.getItem('gemini_api_key');
        if (!k) {
          k = prompt('Enter Gemini API key');
          if (!k) throw new Error('API key is required');
          localStorage.setItem('gemini_api_key', k.trim());
        }
        this.apiKey = k.trim();
      }
    }
    async fetchFromModel(model, prompt) {
      const res = await fetch(`${this.baseUrl}/${model}:generateContent?key=${this.apiKey}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
      });
      return res;
    }
    async generateContent(prompt) {
      if (!this.apiKey) await this.init();
      let response = await this.fetchFromModel(this.primaryModel, prompt);
      if (response.status === 429) response = await this.fetchFromModel(this.fallbackModel, prompt);
      if (!response.ok) throw new Error(`HTTP error ${response.status}`);
      const data = await response.json();
      return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
    }
  }

  // DOM helpers
  const getClosestListItem = (el) => el.closest('[role="listitem"]') || el.closest('.Qr7Oae') || el.closest('.geS5n');
  const getHeadingText = (container) => {
    let t = '';
    if (!container) return t;
    const heading = container.querySelector('[role="heading"] .M7eMe') || container.querySelector('[role="heading"]') || container.querySelector('.HoXoMd .M7eMe');
    if (heading) t = heading.textContent.trim();
    return t || (container.querySelector('label, .aDTYNe, .OIC90c')?.textContent?.trim() || '');
  };
  const plain = (s) => (s || '').replace(/\s+/g, ' ').trim();
  const uid = () => Math.random().toString(36).slice(2, 10);
  const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
  const isVisible = (el) => !!(el && el.offsetParent !== null);

  // prompt builder
  function buildPrompt(schema, oneTimeExtra) {
    const now = new Date().toISOString();
    const extraBlock = oneTimeExtra
      ? `\nUser instructions for this run only. If they conflict, override defaults:\n${oneTimeExtra}\n`
      : '';
    return `
You are a data faker for autofilling Google Forms. Decide realistic but safe dummy values from the provided schema.
Rules:
- Only use provided options for radio, checkbox, and select.
- For "date" fields, return ISO YYYY-MM-DD. Respect "min" and "max" if given, otherwise pick within ±365 days from today.
- For "time" fields, return {"hour":1-12,"minute":0-59,"meridiem":"AM|PM"}.
- Keep text answers short and plausible. Email looks like a real address.
- Return STRICT JSON only. No markdown.
${extraBlock}
Schema (JSON):
${JSON.stringify(schema, null, 2)}

Output JSON array of objects, each:
{ "id": "<schema.id>", "value": <string | string[] | {"hour":number,"minute":number,"meridiem":string}> }

Now generate values. Current time: ${now}.
`;
  }

  // safe JSON
  function safeJson(txt) {
    const cleaned = txt.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
    try { return JSON.parse(cleaned); } catch (e) {
      const m = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
      if (m) return JSON.parse(m[0]);
      throw e;
    }
  }

  // setters
  function setNativeValue(el, value) {
    let proto = el; let setter = null;
    while ((proto = Object.getPrototypeOf(proto)) && !setter) {
      const d = Object.getOwnPropertyDescriptor(proto, 'value');
      if (d && typeof d.set === 'function') setter = d.set;
    }
    if (setter) setter.call(el, value); else el.value = value;
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
  }

  // Select helpers
  function clickOptionInListbox(listbox, wanted) {
    const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
    const open = () => { if (listbox.getAttribute('aria-expanded') === 'false') listbox.click(); };
    const close = () => { if (listbox.getAttribute('aria-expanded') === 'true') listbox.click(); };
    open();
    let popup = Array.from(document.querySelectorAll('.OA0qNb[jsname="V68bde"]')).find(m => isVisible(m));
    let options = popup ? Array.from(popup.querySelectorAll('[role="option"]'))
                        : Array.from(listbox.querySelectorAll('[role="option"]'));
    let target = options.find(o => norm(o.getAttribute('data-value')) === norm(wanted))
             || options.find(o => norm(o.textContent) === norm(wanted))
             || options.find(o => {
                  const txt = norm(o.getAttribute('data-value') || o.textContent);
                  return txt && txt !== 'choose';
                });
    if (target) target.click();
    close();
  }
  function resetListboxToChooseOrFirst(listbox) {
    const open = () => { if (listbox.getAttribute('aria-expanded') === 'false') listbox.click(); };
    const close = () => { if (listbox.getAttribute('aria-expanded') === 'true') listbox.click(); };
    open();
    let popup = Array.from(document.querySelectorAll('.OA0qNb[jsname="V68bde"]')).find(m => isVisible(m));
    let options = popup ? Array.from(popup.querySelectorAll('[role="option"]'))
                        : Array.from(listbox.querySelectorAll('[role="option"]'));
    // Prefer "Choose" / empty value
    let target = options.find(o => /choose/i.test(o.textContent || '') || (o.getAttribute('data-value') || '') === '');
    if (!target && options.length) target = options[0];
    if (target) target.click();
    close();
  }

  // CLEARING LOGIC
  function clearCheckboxesOnly() {
    // Uncheck all checked checkboxes (requested: auto-remove previously set checkmarks before run)
    Array.from(document.querySelectorAll('[role="checkbox"][aria-checked="true"]')).forEach(cb => cb.click());
  }

  function clearAllInputs() {
    // Text-like inputs
    document.querySelectorAll('input.whsOnd[type="text"], input.whsOnd[type="email"], input.whsOnd[type="number"]').forEach(el => setNativeValue(el, ''));
    // Textareas
    document.querySelectorAll('textarea').forEach(el => setNativeValue(el, ''));
    // Dates
    document.querySelectorAll('input[type="date"].whsOnd').forEach(el => setNativeValue(el, ''));
    // Time (Hour/Minute)
    document.querySelectorAll('.PfQ8Lb input[aria-label="Hour"]').forEach(el => setNativeValue(el, ''));
    document.querySelectorAll('.PfQ8Lb input[aria-label="Minute"]').forEach(el => setNativeValue(el, ''));
    // Time AM/PM reset to first option (usually AM)
    document.querySelectorAll('.PfQ8Lb [role="listbox"][aria-label="AM or PM"]').forEach(lb => resetListboxToChooseOrFirst(lb));
    // Checkboxes
    Array.from(document.querySelectorAll('[role="checkbox"][aria-checked="true"]')).forEach(cb => cb.click());
    // Radios (try to deselect active; Forms may keep one selected if required)
    Array.from(document.querySelectorAll('[role="radio"][aria-checked="true"]')).forEach(r => r.click());
    // Select listboxes reset to default
    document.querySelectorAll('[role="listbox"].jgvuAb, [role="listbox"].cGN2le, [role="listbox"].t9kgXb').forEach(lb => resetListboxToChooseOrFirst(lb));
  }

  btnClean.addEventListener('click', () => {
    clearAllInputs();
  });

  // fill from plan
  async function fillFromPlan(plan) {
    document.querySelectorAll('[data-ai-field-id^="text_"]').forEach(input => {
      const id = input.dataset.aiFieldId;
      const p = plan.find(x => x.id === id);
      if (p && typeof p.value === 'string') setNativeValue(input, p.value);
    });

    document.querySelectorAll('[data-ai-field-id^="textarea_"]').forEach(ta => {
      const id = ta.dataset.aiFieldId;
      const p = plan.find(x => x.id === id);
      if (p && typeof p.value === 'string') setNativeValue(ta, p.value);
    });

    document.querySelectorAll('[role="radiogroup"][data-ai-field-id]').forEach(group => {
      const id = group.dataset.aiFieldId;
      const p = plan.find(x => x.id === id);
      if (!p || typeof p.value !== 'string') return;
      const target = Array.from(group.querySelectorAll('[role="radio"]')).find(r => {
        const v = r.getAttribute('data-value') || r.getAttribute('aria-label') || r.textContent;
        return plain(v).toLowerCase() === plain(p.value).toLowerCase();
      }) || Array.from(group.querySelectorAll('[role="radio"]'))[0];
      if (target) target.click();
    });

    const groupIds = new Set(plan.filter(p => Array.isArray(p.value) || typeof p.value === 'string').map(p => p.id));
    groupIds.forEach(id => {
      const planned = plan.find(p => p.id === id);
      const values = Array.isArray(planned?.value) ? planned.value : [planned?.value].filter(Boolean);
      const cbs = Array.from(document.querySelectorAll(`[role="checkbox"][data-ai-field-id="${id}"]`));
      cbs.forEach(cb => { if (cb.getAttribute('aria-checked') === 'true') cb.click(); });
      values.forEach(val => {
        const target = cbs.find(cb => {
          const v = cb.getAttribute('data-answer-value') || cb.getAttribute('aria-label') || cb.textContent;
          return plain(v).toLowerCase() === plain(val).toLowerCase();
        }) || null;
        if (target) target.click();
      });
    });

    document.querySelectorAll('[role="listbox"][data-ai-field-id^="select_"]').forEach(lb => {
      const id = lb.dataset.aiFieldId;
      const p = plan.find(x => x.id === id);
      if (!p || typeof p.value !== 'string') return;
      clickOptionInListbox(lb, p.value);
    });

    document.querySelectorAll('input[type="date"][data-ai-field-id^="date_"]').forEach(dateEl => {
      const id = dateEl.dataset.aiFieldId;
      const p = plan.find(x => x.id === id);
      const min = dateEl.getAttribute('min');
      const max = dateEl.getAttribute('max');
      const value = (p && typeof p.value === 'string') ? p.value : randDate(min, max);
      setNativeValue(dateEl, value);
    });

    const timeGroups = {};
    document.querySelectorAll('[data-ai-field-id^="time_"]').forEach(el => {
      const id = el.dataset.aiFieldId.replace(/_(hour|minute|ampm)$/, '');
      if (!timeGroups[id]) timeGroups[id] = {};
      if (/_hour$/.test(el.dataset.aiFieldId)) timeGroups[id].hourEl = el;
      if (/_minute$/.test(el.dataset.aiFieldId)) timeGroups[id].minuteEl = el;
      if (/_ampm$/.test(el.dataset.aiFieldId)) timeGroups[id].ampmEl = el;
    });
    Object.keys(timeGroups).forEach(id => {
      const grp = timeGroups[id];
      const p = plan.find(x => x.id === id);
      let obj = null;
      if (p && typeof p.value === 'object' && p.value) {
        obj = { hour: clamp(parseInt(p.value.hour, 10) || 9, 1, 12),
                minute: clamp(parseInt(p.value.minute, 10) || 0, 0, 59),
                meridiem: String(p.value.meridiem || 'AM').toUpperCase() };
      } else if (p && typeof p.value === 'string') {
        obj = parseTimeLike(p.value);
      }
      if (!obj) obj = { hour: 10, minute: 30, meridiem: 'AM' };
      if (grp.hourEl) setNativeValue(grp.hourEl, String(obj.hour).padStart(2, '0'));
      if (grp.minuteEl) setNativeValue(grp.minuteEl, String(obj.minute).padStart(2, '0'));
      if (grp.ampmEl) clickOptionInListbox(grp.ampmEl, obj.meridiem);
    });
  }

  // schema scraper
  async function scrapeSchema() {
    const schema = [];

    document.querySelectorAll('input[type="text"].whsOnd, input[type="email"].whsOnd, input[type="number"].whsOnd').forEach((input, idx) => {
      const wrap = getClosestListItem(input);
      const label = getHeadingText(wrap);
      const id = `text_${idx}_${uid()}`;
      input.dataset.aiFieldId = id;
      schema.push({ id, type: 'text', label: plain(label), required: input.required || /[*]$/.test(label) });
    });

    document.querySelectorAll('textarea.KHxj8b, textarea').forEach((ta, idx) => {
      const wrap = getClosestListItem(ta);
      const label = getHeadingText(wrap);
      const id = `textarea_${idx}_${uid()}`;
      ta.dataset.aiFieldId = id;
      schema.push({ id, type: 'textarea', label: plain(label), required: ta.required || /[*]$/.test(label) });
    });

    document.querySelectorAll('[role="radiogroup"]').forEach((rg, idx) => {
      const wrap = getClosestListItem(rg);
      const label = getHeadingText(wrap);
      const options = Array.from(rg.querySelectorAll('[role="radio"]')).map(r => {
        const v = r.getAttribute('data-value') || r.getAttribute('aria-label') || r.textContent;
        return plain(v);
      }).filter(Boolean);
      if (!options.length) return;
      const id = `radio_${idx}_${uid()}`;
      rg.dataset.aiFieldId = id;
      schema.push({ id, type: 'radio', label: plain(label), options: Array.from(new Set(options)) });
    });

    const checkboxEls = Array.from(document.querySelectorAll('[role="checkbox"][data-answer-value], [role="checkbox"][aria-label]'));
    const groups = new Map();
    checkboxEls.forEach((cb) => {
      const wrap = getClosestListItem(cb);
      const groupLabel = getHeadingText(wrap) || 'Checkbox Group';
      const fieldId = cb.getAttribute('data-field-id') || groupLabel;
      const key = `${groupLabel}::${fieldId}`;
      if (!groups.has(key)) groups.set(key, []);
      const v = cb.getAttribute('data-answer-value') || cb.getAttribute('aria-label') || cb.textContent;
      groups.get(key).push({ el: cb, value: plain(v) });
    });
    let cidx = 0;
    for (const [key, items] of groups.entries()) {
      const [groupLabel] = key.split('::');
      const id = `checkbox_${cidx++}_${uid()}`;
      items.forEach(i => { i.el.dataset.aiFieldId = id; });
      const options = Array.from(new Set(items.map(i => i.value))).filter(Boolean);
      if (options.length) schema.push({ id, type: 'checkbox', label: plain(groupLabel), options });
    }

    // Select listboxes and floating popup
    const listboxes = Array.from(document.querySelectorAll('[role="listbox"].jgvuAb, [role="listbox"].cGN2le, [role="listbox"].t9kgXb'));
    for (let idx = 0; idx < listboxes.length; idx++) {
      const lb = listboxes[idx];
      const wrap = getClosestListItem(lb);
      const label = getHeadingText(wrap) || 'Select';
      const id = `select_${idx}_${uid()}`;
      lb.dataset.aiFieldId = id;

      if (lb.getAttribute('aria-expanded') === 'false') lb.click();
      await sleep(10);

      let popup = Array.from(document.querySelectorAll('.OA0qNb[jsname="V68bde"]')).find(m => isVisible(m));
      let opts = popup
        ? Array.from(popup.querySelectorAll('[role="option"]')).map(o => plain(o.getAttribute('data-value') || o.textContent))
        : Array.from(lb.querySelectorAll('[role="option"]')).map(o => plain(o.getAttribute('data-value') || o.textContent));

      if (lb.getAttribute('aria-expanded') === 'true') lb.click();

      opts = opts.filter(v => v && !/^choose$/i.test(v));
      if (opts.length) schema.push({ id, type: 'select', label: plain(label), options: Array.from(new Set(opts)) });
    }

    document.querySelectorAll('input[type="date"].whsOnd').forEach((dateEl, idx) => {
      const wrap = getClosestListItem(dateEl);
      const label = getHeadingText(wrap) || 'Date';
      const id = `date_${idx}_${uid()}`;
      dateEl.dataset.aiFieldId = id;
      schema.push({
        id, type: 'date', label: plain(label),
        min: dateEl.getAttribute('min') || null,
        max: dateEl.getAttribute('max') || null
      });
    });

    document.querySelectorAll('.PfQ8Lb').forEach((timeWrap, idx) => {
      const wrap = getClosestListItem(timeWrap);
      const label = getHeadingText(wrap) || 'Time';
      const hour = timeWrap.querySelector('input[aria-label="Hour"]');
      const minute = timeWrap.querySelector('input[aria-label="Minute"]');
      const meridianBox = timeWrap.querySelector('[role="listbox"][aria-label="AM or PM"]');
      if (hour && minute && meridianBox) {
        const id = `time_${idx}_${uid()}`;
        hour.dataset.aiFieldId = `${id}_hour`;
        minute.dataset.aiFieldId = `${id}_minute`;
        meridianBox.dataset.aiFieldId = `${id}_ampm`;
        const options = Array.from(meridianBox.querySelectorAll('[role="option"]')).map(o => plain(o.getAttribute('data-value') || o.textContent));
        schema.push({ id, type: 'time', label: plain(label), options: Array.from(new Set(options)) });
      }
    });

    return schema;
  }

  // main
  async function run() {
    const original = btn.textContent;
    btn.disabled = true; btn.textContent = '...';
    try {
      // Auto-remove previously set checkmarks (checkboxes) before running fill
      clearCheckboxesOnly();

      const schema = await scrapeSchema();
      if (!schema.length) throw new Error('No fields detected');

      const prompt = buildPrompt(schema, sessionExtraInstructions);
      // clear one-time instructions after use
      sessionExtraInstructions = '';
      btnEdit.style.background = '#444';

      const gemini = new GeminiClient();
      const txt = await gemini.generateContent(prompt);
      const plan = safeJson(txt);
      await fillFromPlan(plan);
      btn.textContent = '✓';
      await sleep(1200);
    } catch (e) {
      console.error(e);
      alert(e.message || 'AI fill failed');
    } finally {
      btn.disabled = false; btn.textContent = original;
    }
  }

  btn.addEventListener('click', run);
})();