Universal Captcha Learner — FULL (reCAPTCHA + hCaptcha)

Learns image-based reCAPTCHA & hCaptcha selections (auto-select known, highlight unknown, GUI to manage DB). Stores data via GM_setValue / GM_getValue.

当前为 2025-09-30 提交的版本,查看 最新版本

// ==UserScript==
// @name         Universal Captcha Learner — FULL (reCAPTCHA + hCaptcha)
// @namespace    https://example.com
// @version      1.3
// @description  Learns image-based reCAPTCHA & hCaptcha selections (auto-select known, highlight unknown, GUI to manage DB). Stores data via GM_setValue / GM_getValue.
// @author       You
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @run-at       document-end
// ==/UserScript==

(function(){
  'use strict';
  const POLL_INTERVAL = 1500; // ms - how often to scan page for captchas
  const DB_PREFIX = 'captcha_learner_v1_';
  const MAX_IMG_DIM = 160; // scale images for hashing
  const LOG = true; // set false to reduce console logs

  // ---------- Utilities ----------
  function log(...args){ if(LOG) console.log('[CaptchaLearner]', ...args); }
  function gmGet(key, def){ try { const v = GM_getValue(key); return typeof v === 'undefined' ? def : v; } catch(e){ return def; } }
  function gmSet(key, val){ try { GM_setValue(key, val); } catch(e){ console.error(e); } }
  function gmList(){ try { return GM_listValues(); } catch(e){ return []; } }
  function gmDelete(key){ try { GM_deleteValue(key); } catch(e){ console.error(e); } }

  // ---------- UI: info box + control panel ----------
  const INFO_ID = 'captcha_learner_info_box';
  const PANEL_ID = 'captcha_learner_control_panel';

  function ensureStyles(){
    if(document.getElementById('captcha_learner_css')) return;
    const css = `
      #${INFO_ID}{ position:fixed; left:14px; top:14px; z-index:2147483647; background:#222; color:#fff; padding:12px 14px; border-radius:10px; font-family:Arial, sans-serif; font-size:13px; box-shadow:0 6px 18px rgba(0,0,0,0.4); max-width:320px; }
      #${INFO_ID} .title{ font-weight:700; margin-bottom:6px; color:#fff; }
      #${INFO_ID} .msg{ color:#ddd; font-size:12px; line-height:1.35; }
      #${PANEL_ID}{ position:fixed; right:14px; bottom:14px; z-index:2147483647; background:#fff; color:#111; padding:12px; border-radius:10px; width:420px; font-family:Arial, sans-serif; font-size:13px; box-shadow:0 6px 18px rgba(0,0,0,0.15); }
      #${PANEL_ID} .row{ margin-bottom:8px; }
      #${PANEL_ID} textarea{ width:100%; height:120px; font-family:monospace; font-size:12px; }
      #captcha_learner_db_list{ max-height:200px; overflow:auto; border:1px solid #eee; padding:6px; border-radius:6px; background:#fafafa; }
      .cl-entry{ padding:6px; border-bottom:1px dashed #eee; display:flex; justify-content:space-between; align-items:center; }
      .cl-entry .k{ font-weight:600; color:#333; }
      .cl-entry button{ margin-left:8px; }
      .cl-img-thumb{ max-width:60px; max-height:50px; border:1px solid #ccc; margin-right:6px; }
      .cl-highlight{ outline:3px solid #ff6b6b !important; }
    `;
    const style = document.createElement('style');
    style.id = 'captcha_learner_css';
    style.innerText = css;
    document.head.appendChild(style);
  }

  function ensureInfoBox(){
    if(document.getElementById(INFO_ID)) return;
    ensureStyles();
    const div = document.createElement('div');
    div.id = INFO_ID;
    div.innerHTML = '<div class="title">Captcha Learner</div><div class="msg">Scanning page...</div>';
    document.body.appendChild(div);
  }

  function updateInfo(msg){
    ensureInfoBox();
    const el = document.getElementById(INFO_ID);
    el.querySelector('.msg').innerText = msg;
  }

  function ensureControlPanel(){
    if(document.getElementById(PANEL_ID)) return;
    ensureStyles();
    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.innerHTML = `
      <div style="font-weight:700;margin-bottom:8px">Captcha Learner — Settings & DB</div>
      <div class="row">
        <label>Auto-scan interval (ms): <input id="cl_interval" type="number" value="${POLL_INTERVAL}" style="width:100px"></label>
        <label style="margin-left:10px"><input id="cl_autostart" type="checkbox" checked> Auto</label>
      </div>
      <div class="row">
        <button id="cl_refresh_btn">Scan now</button>
        <button id="cl_export_btn">Export DB</button>
        <button id="cl_import_btn">Import DB</button>
        <button id="cl_clear_btn" style="float:right;background:#d9534f;color:#fff;border:none;padding:6px 10px;border-radius:6px">Clear DB</button>
      </div>
      <div class="row"><div id="captcha_learner_db_list"></div></div>
      <div class="row">
        <div style="font-size:12px;color:#666">Tip: click an unknown image on captcha to teach the label. Stored locally via GM_setValue.</div>
      </div>
      <div style="margin-top:6px"><textarea id="cl_export_area" placeholder="Paste JSON here to import"></textarea></div>
    `;
    document.body.appendChild(panel);

    document.getElementById('cl_refresh_btn').addEventListener('click', ()=> scanOnce(true));
    document.getElementById('cl_export_btn').addEventListener('click', exportDB);
    document.getElementById('cl_import_btn').addEventListener('click', importDBFromText);
    document.getElementById('cl_clear_btn').addEventListener('click', clearDB);
  }

  // ---------- Image hashing / base64 ----------
  function downscaleImgToCanvas(img){
    // returns dataURL resized
    try {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      // scale preserving aspect ratio
      const w = img.naturalWidth || img.width;
      const h = img.naturalHeight || img.height;
      const scale = Math.min(1, MAX_IMG_DIM / Math.max(w,h));
      const nw = Math.max(20, Math.round(w * scale));
      const nh = Math.max(20, Math.round(h * scale));
      canvas.width = nw;
      canvas.height = nh;
      ctx.drawImage(img, 0, 0, nw, nh);
      // use jpeg small quality
      return canvas.toDataURL('image/jpeg', 0.45);
    } catch(e){
      log('downscale error', e);
      return null;
    }
  }

  // ---------- DB helpers ----------
  function dbKeyForLabel(label){
    return DB_PREFIX + label.replace(/\s+/g, '_').toLowerCase();
  }
  function dbGet(label){ return gmGet(dbKeyForLabel(label), []); }
  function dbAdd(label, dataUrl){
    const key = dbKeyForLabel(label);
    let arr = gmGetValueSafe(key, []);
    if(!Array.isArray(arr)) arr = [];
    if(!arr.includes(dataUrl)){
      arr.push(dataUrl);
      gmSet(key, arr);
      log('DB add', label);
    }
  }
  function dbRemove(label){ gmDelete(dbKeyForLabel(label)); }
  function dbList(){
    const keys = gmList();
    const out = [];
    for(const k of keys){
      if(k.startsWith(DB_PREFIX)){
        out.push({key:k, label:k.slice(DB_PREFIX.length), count: (gmGetValueSafe(k,[])).length});
      }
    }
    return out;
  }

  function gmGetValueSafe(k, def){
    try{ const v = GM_getValue(k); return typeof v === 'undefined' ? def : v; } catch(e){ return def; }
  }

  // ---------- Captcha detection & processing ----------
  function findReCaptchaWidget(){
    // reCAPTCHA image challenge tiles have class rc-image-tile-wrapper containing <img>
    const tiles = document.querySelectorAll('.rc-imageselect-tile-wrapper img, .rc-image-tile-wrapper img');
    const instruct = document.querySelector('.rc-imageselect-instructions, .rc-imageselect-instructions div, .rc-imageselect-desc-no-canonical');
    if(tiles && tiles.length && instruct){
      return {type:'recaptcha', tiles:Array.from(tiles), label: instruct.innerText.trim()};
    }
    return null;
  }

  function findHcaptchaWidget(){
    // hCaptcha images are inside .task-image or .image-grid
    const tiles = document.querySelectorAll('.task-image img, .image-grid img, .challenge-image img');
    const instruct = document.querySelector('.prompt-text, .caption-text');
    if(tiles && tiles.length && instruct){
      return {type:'hcaptcha', tiles:Array.from(tiles), label: instruct.innerText.trim()};
    }
    return null;
  }

  function highlightNode(node){
    try{
      node.classList.add('cl-highlight');
    }catch(e){}
  }

  function attachLearnClick(imgEl, label){
    // on click, add to DB and give feedback
    const handler = (ev)=>{
      ev.stopPropagation(); ev.preventDefault();
      const data = downscaleImgToCanvas(imgEl);
      if(!data) return;
      dbAdd(label, data);
      updateInfo('Learned image for "'+label+'" — saved.');
      // remove highlight
      imgEl.classList.remove('cl-highlight');
      // remove handler to prevent duplicates
      imgEl.removeEventListener('click', handler);
    };
    imgEl.addEventListener('click', handler);
  }

  function tryAutoSelect(tileImg, label){
    const data = downscaleImgToCanvas(tileImg);
    if(!data) return false;
    const arr = dbGet(label) || [];
    if(arr.includes(data)){
      // attempt to click the tile container
      const clickable = tileImg.closest('button, td, div, a, span') || tileImg;
      try { clickable.click(); } catch(e){ tileImg.click(); }
      return true;
    }
    return false;
  }

  function processWidget(widget){
    if(!widget) return;
    updateInfo(widget.type + ' — "'+widget.label+'" — '+widget.tiles.length+' images');
    let matched = 0;
    for(const img of widget.tiles){
      try{
        const ok = tryAutoSelect(img, widget.label);
        if(ok) matched++;
        else {
          highlightNode(img);
          attachLearnClick(img, widget.label);
        }
      }catch(e){ log('process tile error', e); }
    }
    if(matched) updateInfo('Auto-selected '+matched+' images for "'+widget.label+'"');
  }

  // ---------- Scanning loop ----------
  let POLLER = null;
  function scanOnce(force){
    try{
      const rec = findReCaptchaWidget();
      if(rec){ processWidget(rec); return; }
      const h = findHcaptchaWidget();
      if(h){ processWidget(h); return; }
      if(force) updateInfo('No known captcha widget found.');
    }catch(e){ log('scanOnce err', e); }
  }

  function startPoller(interval){
    if(POLLER) clearInterval(POLLER);
    POLLER = setInterval(()=> scanOnce(false), interval || POLL_INTERVAL);
  }

  // ---------- Control panel actions ----------
  function renderDBList(){
    const container = document.getElementById('captcha_learner_db_list');
    if(!container) return;
    container.innerHTML = '';
    const items = dbList();
    if(items.length === 0) { container.innerHTML = '<div style="color:#888;padding:8px">DB empty</div>'; return; }
    for(const it of items){
      const row = document.createElement('div');
      row.className = 'cl-entry';
      const left = document.createElement('div');
      left.style.display = 'flex'; left.style.alignItems='center';
      // show small thumb from first image
      const arr = gmGetValueSafe(it.key, []);
      const thumb = document.createElement('img');
      thumb.className = 'cl-img-thumb';
      thumb.src = arr && arr[0] ? arr[0] : '';
      left.appendChild(thumb);
      const title = document.createElement('div');
      title.innerHTML = '<div class="k">'+it.label+'</div><div style="font-size:11px;color:#666">'+it.count+' images</div>';
      left.appendChild(title);
      row.appendChild(left);
      const controls = document.createElement('div');
      const del = document.createElement('button'); del.textContent='Delete'; del.style.padding='6px 8px';
      del.addEventListener('click', ()=>{ if(confirm('Delete "'+it.label+'"?')){ dbRemove(it.label); renderDBList(); }});
      const view = document.createElement('button'); view.textContent='View'; view.style.marginLeft='8px'; view.style.padding='6px 8px';
      view.addEventListener('click', ()=>{ const arr = gmGetValueSafe(it.key,[]); document.getElementById('cl_export_area').value = JSON.stringify({label:it.label, images:arr}, null, 2); });
      controls.appendChild(view); controls.appendChild(del);
      row.appendChild(controls);
      container.appendChild(row);
    }
  }

  function exportDB(){
    // build JSON of all DB items
    const items = dbList();
    const out = {};
    for(const it of items){
      out[it.label] = gmGetValueSafe(DB_PREFIX + it.label, []);
    }
    const txt = JSON.stringify(out, null, 2);
    document.getElementById('cl_export_area').value = txt;
    updateInfo('DB exported to textarea. You can copy and save it.');
  }

  function importDBFromText(){
    const txt = document.getElementById('cl_export_area').value.trim();
    if(!txt) return alert('Paste JSON into textarea first');
    try{
      const parsed = JSON.parse(txt);
      for(const k in parsed){
        const key = DB_PREFIX + k;
        gmSet(key, parsed[k]);
      }
      updateInfo('Imported DB entries: '+Object.keys(parsed).length);
      renderDBList();
    }catch(e){ alert('Invalid JSON: '+e.message); }
  }

  function clearDB(){
    if(!confirm('Clear all learned DB entries?')) return;
    const keys = gmList();
    for(const k of keys){
      if(k.startsWith(DB_PREFIX)) gmDelete(k);
    }
    renderDBList();
    updateInfo('DB cleared');
  }

  // ---------- Init ----------
  function init(){
    ensureInfoBox();
    ensureControlPanel();
    renderDBList();
    // start poller
    const intervalInput = document.getElementById('cl_interval');
    intervalInput.addEventListener('change', ()=> {
      const v = parseInt(intervalInput.value) || POLL_INTERVAL;
      startPoller(v);
    });
    document.getElementById('cl_autostart').checked = true;
    startPoller(POLL_INTERVAL);
    // refresh db list periodically
    setInterval(renderDBList, 4000);
    // one initial scan
    setTimeout(()=> scanOnce(true), 1500);
  }

  // start
  init();

})();