reCAPTCHA Badge Visibility Notifier

Detect and show reCAPTCHA badge. Always assume Google-owned sites use it. Continuously check for late-injected elements. User can choose fade-out behavior on first use.

// ==UserScript==
// @name         reCAPTCHA Badge Visibility Notifier
// @version      1.9.1
// @description  Detect and show reCAPTCHA badge. Always assume Google-owned sites use it. Continuously check for late-injected elements. User can choose fade-out behavior on first use.
// @author       EthanJoyce
// @namespace    https://github.com/Ethanjoyce2010/Recaptcha-notifier
// @license      MIT
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==
(function() {
    'use strict';

    // ====== CONFIGURATION ======
    const FADE_OUT_TIME = 2000; // milliseconds (only used if fade out is enabled)
    // ============================

  // Tracks the last alert type shown: 'present', 'absent', or null
  let lastAlertType = null;
    let badgeFound = false;
    let fadeOutEnabled = null; // Will be set based on user choice or saved preference

    // Add styles for the alert box and choice dialog
    GM_addStyle(`
      .recaptcha-alert {
        position: fixed;
        top: 20px;
        left: 20px;
        background: #333;
        color: #fff;
        padding: 10px 15px;
        border-radius: 6px;
        font-size: 14px;
        z-index: 999999;
        opacity: 0.95;
        cursor: pointer;
        transition: opacity 0.5s ease-out;
        font-weight: normal;
      }
      .recaptcha-alert.recaptcha-red {
        background: #d32f2f !important;
        color: #fff !important;
      }
      .recaptcha-alert.recaptcha-bold {
        font-weight: bold !important;
      }
      .recaptcha-alert.fade-out {
        opacity: 0;
      }
      .recaptcha-choice-dialog {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: #fff;
        color: #333;
        padding: 20px;
        border-radius: 8px;
        box-shadow: 0 4px 20px rgba(0,0,0,0.3);
        z-index: 1000000;
        font-family: Arial, sans-serif;
        max-width: 400px;
        text-align: center;
      }
      .recaptcha-choice-dialog h3 {
        margin: 0 0 15px 0;
        font-size: 18px;
      }
      .recaptcha-choice-dialog p {
        margin: 0 0 20px 0;
        font-size: 14px;
        line-height: 1.4;
      }
      .recaptcha-choice-buttons {
        display: flex;
        gap: 10px;
        justify-content: center;
      }
      .recaptcha-choice-btn {
        padding: 8px 16px;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        transition: background-color 0.2s;
      }
      .recaptcha-choice-btn.auto {
        background: #4CAF50;
        color: white;
      }
      .recaptcha-choice-btn.auto:hover {
        background: #45a049;
      }
      .recaptcha-choice-btn.manual {
        background: #2196F3;
        color: white;
      }
      .recaptcha-choice-btn.manual:hover {
        background: #1976D2;
      }
      .recaptcha-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0,0,0,0.5);
        z-index: 999999;
      }
    `);

  // Preference helpers: try GM_* APIs (sync or promise), GM.* APIs, then localStorage fallback
  async function getPref(key, defaultValue) {
    try {
      // Greasemonkey/Tampermonkey legacy functions
      if (typeof GM_getValue === 'function') {
        const value = GM_getValue(key, defaultValue);
        // If it returned a Promise (modern GM), await it
        if (value != null && typeof value.then === 'function') return await value;
        return (typeof value === 'undefined') ? defaultValue : value;
      }

      // Newer GM.* API
      if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') {
        const v = await GM.getValue(key, defaultValue);
        return (typeof v === 'undefined') ? defaultValue : v;
      }
    } catch (e) {
      // fallthrough to localStorage
    }

    try {
      const raw = localStorage.getItem('recaptcha_' + key);
      if (raw === null) return defaultValue;
      return JSON.parse(raw);
    } catch (e) {
      return defaultValue;
    }
  }

  async function setPref(key, value) {
    try {
      if (typeof GM_setValue === 'function') {
        const res = GM_setValue(key, value);
        if (res && typeof res.then === 'function') await res;
        return;
      }

      if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') {
        await GM.setValue(key, value);
        return;
      }
    } catch (e) {
      // fallthrough to localStorage
    }

    try {
      localStorage.setItem('recaptcha_' + key, JSON.stringify(value));
    } catch (e) {
      // ignore
    }
  }

  function showChoiceDialog() {
        return new Promise((resolve) => {
            // Create overlay
            const overlay = document.createElement("div");
            overlay.className = "recaptcha-overlay";

            // Create dialog
            const dialog = document.createElement("div");
            dialog.className = "recaptcha-choice-dialog";
            dialog.innerHTML = `
                <h3>reCAPTCHA Notification Settings</h3>
                <p>How would you like reCAPTCHA notifications to behave?</p>
                <p>(You can change this later by clicking the gear icon in notifications)</p>
                <p>(Will apply when reloaded)</p>
                <div class="recaptcha-choice-buttons">
                    <button class="recaptcha-choice-btn auto">Auto-fade (2s)</button>
                    <button class="recaptcha-choice-btn manual">Stay until clicked</button>
                </div>
            `;

            // Add event listeners
            const autoBtn = dialog.querySelector('.auto');
            const manualBtn = dialog.querySelector('.manual');

      autoBtn.addEventListener('click', async () => {
        await setPref('fadeOutEnabled', true);
        fadeOutEnabled = true;
        document.body.removeChild(overlay);
        resolve(true);
      });

      manualBtn.addEventListener('click', async () => {
        await setPref('fadeOutEnabled', false);
        fadeOutEnabled = false;
        document.body.removeChild(overlay);
        resolve(false);
      });

            overlay.appendChild(dialog);
            document.body.appendChild(overlay);
        });
    }

  async function initializeFadeOutSetting() {
    const savedSetting = await getPref('fadeOutEnabled', null);

    if (savedSetting === null) {
      // First time use - show choice dialog
      fadeOutEnabled = await showChoiceDialog();
    } else {
      // Use saved setting
      fadeOutEnabled = savedSetting;
    }
  }

  function showAlert(message, type, opts = {}) {
    // type is 'present' or 'absent'. Only suppress if it's identical to lastAlertType
    if (type && lastAlertType === type) return;
    lastAlertType = type || null;

    const alertBox = document.createElement("div");
    alertBox.className = "recaptcha-alert";

    // Add red background if Google site or reCAPTCHA present
    if (opts.isGoogle || opts.isRecaptcha) {
      alertBox.classList.add('recaptcha-red');
    }
    // Add bold if Google site
    if (opts.isGoogle) {
      alertBox.classList.add('recaptcha-bold');
    }

    // Message container
    const msg = document.createElement('span');
    msg.textContent = message;
    alertBox.appendChild(msg);

    // Settings gear to reopen choice dialog
    const gear = document.createElement('button');
    gear.title = 'Notification settings';
    gear.style.marginLeft = '10px';
    gear.style.background = 'transparent';
    gear.style.border = 'none';
    gear.style.color = 'inherit';
    gear.style.cursor = 'pointer';
    gear.textContent = '⚙️';
    gear.addEventListener('click', async (e) => {
      e.stopPropagation();
      await showChoiceDialog();
    });
    alertBox.appendChild(gear);

    // Remove on click
    alertBox.addEventListener("click", () => {
      alertBox.remove();
    });

    document.body.appendChild(alertBox);

    if (fadeOutEnabled) {
      setTimeout(() => {
        alertBox.classList.add("fade-out");
        setTimeout(() => {
          if (alertBox.parentNode) {
            alertBox.remove();
          }
        }, 500);
      }, FADE_OUT_TIME);
    }
  }

  function checkReCaptcha() {
    // Returns true if reCAPTCHA is detected by any heuristic.
    // Heuristics: badge, .g-recaptcha or data-sitekey, grecaptcha object, script/iframe srcs, inline script text.
    let found = false;
    try {
      // 1) Visible badge
      const badge = document.querySelector('.grecaptcha-badge');
      if (badge) {
        try { badge.style.visibility = 'visible'; } catch(e) {}
        showAlert('This site uses reCAPTCHA', 'present', { isRecaptcha: true });
        return true;
      }

      // 2) Common widget markers: g-recaptcha class or data-sitekey attribute
      const widget = document.querySelector('.g-recaptcha, [data-sitekey]');
      if (widget) {
        showAlert('This site uses reCAPTCHA', 'present', { isRecaptcha: true });
        return true;
      }

      // 3) grecaptcha JS object (render, enterprise, etc.)
      if (typeof window.grecaptcha !== 'undefined') {
        try {
          if (window.grecaptcha && (typeof window.grecaptcha.render === 'function' || window.grecaptcha.enterprise)) {
            showAlert('This site uses reCAPTCHA', 'present', { isRecaptcha: true });
            return true;
          }
        } catch (e) {}
      }

      // 4) External scripts that reference reCAPTCHA
      const scripts = Array.from(document.getElementsByTagName('script'));
      for (let i = 0; i < scripts.length; i++) {
        const s = scripts[i];
        const src = s.src || '';
        if (src && /recaptcha|google.*recaptcha|recaptcha\/api/i.test(src)) {
          showAlert('This site uses reCAPTCHA', 'present', { isRecaptcha: true });
          return true;
        }
        // Inline script content may reference grecaptcha
        if (!src && s.textContent && /grecaptcha|recaptcha/i.test(s.textContent)) {
          showAlert('This site uses reCAPTCHA', 'present', { isRecaptcha: true });
          return true;
        }
      }

      // 5) Iframes that load recaptcha content
      const iframes = Array.from(document.getElementsByTagName('iframe'));
      for (let i = 0; i < iframes.length; i++) {
        const f = iframes[i];
        const src = f.src || '';
        if (src && /recaptcha|google.*recaptcha/i.test(src)) {
          showAlert('This site uses reCAPTCHA', 'present', { isRecaptcha: true });
          return true;
        }
      }
    } catch (e) {
      // Swallow errors from odd pages
    }

    return false;
  }

    // Special case: Google-owned sites -> always assume yes
  function isGoogleSite() {
    const host = window.location.hostname;
    return host.endsWith('.google.com') ||
         host.endsWith('.youtube.com') ||
         host.endsWith('.blogger.com') ||
         host.endsWith('.gmail.com');
  }

  window.addEventListener("load", async () => {
    // Initialize fade-out setting first
    await initializeFadeOutSetting();

    setTimeout(() => {
      if (isGoogleSite()) {
        showAlert("This Google site uses reCAPTCHA", 'present', { isGoogle: true, isRecaptcha: true });
      } else if (!checkReCaptcha()) {
        showAlert("This site does NOT use reCAPTCHA", 'absent');
      }

      // Watch for late injection
      const observer = new MutationObserver(() => checkReCaptcha());
      observer.observe(document.body, { childList: true, subtree: true });

      // Poll every 1s until found
      const interval = setInterval(() => {
        if (checkReCaptcha()) {
          clearInterval(interval);
        }
      }, 1000);
    }, 1000);
  });
})();