// ==UserScript==
// @name Universal Captcha Learner — FULL with Movable Panel & Auto-Clear
// @namespace https://example.com
// @version 1.4
// @description Learns image-based reCAPTCHA & hCaptcha selections (auto-select known, highlight unknown, GUI to manage DB). Panel is movable/collapsible and position is remembered. Can auto-clear DB on start.
// @author You
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_deleteValue
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
/* ----------------- Конфигурация ----------------- */
const DB_PREFIX = 'captcha_learner_v1_';
const DEFAULT_POLL = 1500; // ms
const MAX_IMG_DIM = 160; // px (для downscale)
const MIN_IMG_DIM = 20;
const AUTO_START_DEFAULT = true;
const SETTINGS_KEY = 'cl_settings_v1';
/* ------------------------------------------------ */
// ---------- helpers для GM storage ----------
function gmSet(key, val) { try { GM_setValue(key, val); } catch (e) { console.error(e); } }
function gmGet(key, def) { try { const v = GM_getValue(key); return typeof v === 'undefined' ? def : v; } catch (e) { return def; } }
function gmList() { try { return GM_listValues(); } catch (e) { return []; } }
function gmDelete(key) { try { GM_deleteValue(key); } catch (e) { console.error(e); } }
// Загрузить/сохранить общие настройки панели
function loadSettings() {
const defaultSettings = {
pollInterval: DEFAULT_POLL,
autoStart: AUTO_START_DEFAULT,
autoClearOnStart: false,
collapsed: false,
left: null,
top: null
};
try {
const s = GM_getValue(SETTINGS_KEY);
if (!s) { gmSet(SETTINGS_KEY, defaultSettings); return defaultSettings; }
return Object.assign({}, defaultSettings, s);
} catch (e) { return defaultSettings; }
}
function saveSettings(s) { gmSet(SETTINGS_KEY, s); }
let SETTINGS = loadSettings();
// ---------- UI: стили, панель (movable, collapse) ----------
const INFO_ID = 'cl_info_box';
const PANEL_ID = 'cl_panel';
function ensureStyles() {
if (document.getElementById('cl_css')) return;
const css = `
#${INFO_ID}{ position:fixed; left:14px; top:14px; z-index:2147483647; background:#111; color:#fff; padding:10px 12px; border-radius:10px; font-family:Arial, sans-serif; font-size:13px; box-shadow:0 6px 20px rgba(0,0,0,0.4); max-width:340px;}
#${PANEL_ID}{ position:fixed; right:14px; bottom:14px; z-index:2147483647; width:420px; font-family:Arial, sans-serif; font-size:13px; box-shadow:0 6px 20px rgba(0,0,0,0.2); border-radius:10px; overflow:hidden; background:#fff;}
#${PANEL_ID} .header{ background:#0f4c81; color:#fff; padding:8px 10px; cursor:move; display:flex; align-items:center; justify-content:space-between;}
#${PANEL_ID} .body{ padding:10px; background:#fff; color:#111;}
#${PANEL_ID} input[type="number"]{ width:90px; }
#${PANEL_ID} textarea{ width:100%; height:120px; font-family:monospace; font-size:12px; margin-top:6px; }
#cl_db_list{ max-height:200px; overflow:auto; border:1px solid #eee; padding:6px; border-radius:6px; background:#fafafa; }
.cl_entry{ display:flex; justify-content:space-between; align-items:center; padding:6px; border-bottom:1px dashed #eee; }
.cl_thumb{ max-width:54px; max-height:44px; border:1px solid #ccc; margin-right:8px; }
.cl_highlight{ outline:3px solid #ff9a3c !important; }
.cl_btn{ padding:6px 8px; margin-left:6px; border-radius:6px; border:none; cursor:pointer; }
.cl_btn_red{ background:#d9534f; color:#fff; }
.cl_btn_primary{ background:#0f4c81; color:#fff; }
`;
const st = document.createElement('style');
st.id = 'cl_css';
st.innerText = css;
document.head.appendChild(st);
}
function ensureInfoBox() {
if (document.getElementById(INFO_ID)) return;
ensureStyles();
const el = document.createElement('div');
el.id = INFO_ID;
el.innerHTML = `<div style="font-weight:700;margin-bottom:6px">Captcha Learner</div><div id="cl_info_msg">Сканирование страницы...</div>`;
document.body.appendChild(el);
}
function updateInfo(msg) {
ensureInfoBox();
const el = document.getElementById('cl_info_msg');
if (el) el.textContent = msg;
}
// Позиция и состояние панели
function ensurePanel() {
if (document.getElementById(PANEL_ID)) return;
ensureStyles();
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="header"><div style="font-weight:700">Captcha Learner — Controls</div>
<div>
<button id="cl_toggle_btn" class="cl_btn cl_btn_primary">–</button>
</div>
</div>
<div class="body" id="cl_body">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
<label>Interval(ms): <input id="cl_interval" type="number" value="${SETTINGS.pollInterval}" /></label>
<label><input id="cl_autostart" type="checkbox" ${SETTINGS.autoStart ? 'checked' : ''}/> Auto</label>
<label style="margin-left:8px"><input id="cl_autoclear" type="checkbox" ${SETTINGS.autoClearOnStart ? 'checked' : ''}/> Auto-clear on start</label>
</div>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button id="cl_scan_btn" class="cl_btn cl_btn_primary">Scan now</button>
<button id="cl_export_btn" class="cl_btn">Export DB</button>
<button id="cl_import_btn" class="cl_btn">Import DB</button>
<button id="cl_clear_btn" class="cl_btn cl_btn_red">Clear DB</button>
</div>
<div id="cl_db_list" style="margin-bottom:8px"></div>
<textarea id="cl_import_area" placeholder='JSON for import / exported JSON will appear here'></textarea>
</div>
`;
document.body.appendChild(panel);
// restore position if saved
if (SETTINGS.left && SETTINGS.top) {
panel.style.left = SETTINGS.left;
panel.style.top = SETTINGS.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
}
// collapsed state
const body = panel.querySelector('#cl_body');
const toggleBtn = panel.querySelector('#cl_toggle_btn');
function setCollapsed(v) {
SETTINGS.collapsed = !!v;
saveSettings(SETTINGS);
body.style.display = v ? 'none' : 'block';
toggleBtn.textContent = v ? '+' : '–';
}
setCollapsed(SETTINGS.collapsed);
toggleBtn.addEventListener('click', () => setCollapsed(!SETTINGS.collapsed));
// drag
let dragging = false, dx = 0, dy = 0;
const header = panel.querySelector('.header');
header.addEventListener('mousedown', (e) => {
dragging = true;
dx = e.clientX - panel.offsetLeft;
dy = e.clientY - panel.offsetTop;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
panel.style.left = (e.clientX - dx) + 'px';
panel.style.top = (e.clientY - dy) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (dragging) {
dragging = false;
SETTINGS.left = panel.style.left;
SETTINGS.top = panel.style.top;
saveSettings(SETTINGS);
}
});
// controls hookup
document.getElementById('cl_scan_btn').addEventListener('click', () => scanOnce(true));
document.getElementById('cl_export_btn').addEventListener('click', exportDB);
document.getElementById('cl_import_btn').addEventListener('click', importDBFromTextarea);
document.getElementById('cl_clear_btn').addEventListener('click', () => {
if (!confirm('Clear ALL learned DB entries?')) return;
clearDB();
});
// settings inputs
document.getElementById('cl_interval').addEventListener('change', (e) => {
const v = Math.max(200, parseInt(e.target.value) || DEFAULT_POLL);
SETTINGS.pollInterval = v;
saveSettings(SETTINGS);
restartPoller();
});
document.getElementById('cl_autostart').addEventListener('change', (e) => {
SETTINGS.autoStart = !!e.target.checked;
saveSettings(SETTINGS);
if (SETTINGS.autoStart) startPoller(); else stopPoller();
});
document.getElementById('cl_autoclear').addEventListener('change', (e) => {
SETTINGS.autoClearOnStart = !!e.target.checked;
saveSettings(SETTINGS);
});
}
// ---------- DB helpers ----------
function dbKey(label) { return DB_PREFIX + label.replace(/\s+/g, '_').toLowerCase(); }
function dbGet(label) { return gmGet(dbKey(label), []); }
function dbSet(label, arr) { gmSet(dbKey(label), arr); }
function dbAdd(label, dataUrl) {
const key = dbKey(label);
let arr = gmGet(key, []);
if (!Array.isArray(arr)) arr = [];
if (!arr.includes(dataUrl)) {
arr.push(dataUrl);
gmSet(key, arr);
}
}
function dbRemove(label) { gmDelete(dbKey(label)); }
function dbList() {
const keys = gmList();
const out = [];
for (const k of keys) {
if (k.startsWith(DB_PREFIX)) {
const label = k.slice(DB_PREFIX.length);
const arr = gmGet(k, []);
out.push({ label: label, count: (Array.isArray(arr) ? arr.length : 0) });
}
}
return out;
}
function clearDB() {
const keys = gmList();
for (const k of keys) {
if (k.startsWith(DB_PREFIX)) gmDelete(k);
}
renderDBList();
updateInfo('DB cleared');
}
function exportDB() {
const items = dbList();
const out = {};
for (const it of items) {
out[it.label] = gmGet(DB_PREFIX + it.label, []);
}
document.getElementById('cl_import_area').value = JSON.stringify(out, null, 2);
updateInfo('Exported DB to textarea');
}
function importDBFromTextarea() {
const txt = document.getElementById('cl_import_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]);
}
renderDBList();
updateInfo('Imported ' + Object.keys(parsed).length + ' entries');
} catch (e) {
alert('Invalid JSON: ' + e.message);
}
}
function renderDBList() {
const c = document.getElementById('cl_db_list');
if (!c) return;
c.innerHTML = '';
const items = dbList();
if (items.length === 0) { c.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';
const arr = gmGet(DB_PREFIX + it.label, []);
const thumb = document.createElement('img'); thumb.className = 'cl_thumb'; thumb.src = arr && arr[0] ? arr[0] : '';
left.appendChild(thumb);
const t = document.createElement('div'); t.innerHTML = `<div style="font-weight:700">${it.label}</div><div style="font-size:12px;color:#666">${it.count} images</div>`;
left.appendChild(t);
row.appendChild(left);
const btns = document.createElement('div');
const view = document.createElement('button'); view.textContent = 'View'; view.className='cl_btn';
view.onclick = () => { document.getElementById('cl_import_area').value = JSON.stringify({ label: it.label, images: gmGet(DB_PREFIX + it.label, []) }, null, 2); };
const del = document.createElement('button'); del.textContent = 'Delete'; del.className='cl_btn cl_btn_red';
del.onclick = () => { if (confirm('Delete "' + it.label + '"?')) { dbRemove(it.label); renderDBList(); } };
btns.appendChild(view); btns.appendChild(del);
row.appendChild(btns);
c.appendChild(row);
}
}
// ---------- image downscale & signature ----------
function downscaleToDataUrl(img) {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
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(MIN_IMG_DIM, Math.round(w * scale));
const nh = Math.max(MIN_IMG_DIM, Math.round(h * scale));
canvas.width = nw; canvas.height = nh;
ctx.drawImage(img, 0, 0, nw, nh);
return canvas.toDataURL('image/jpeg', 0.45);
} catch (e) {
console.error('downscale error', e);
return null;
}
}
// ---------- detection of widgets ----------
function findRecaptcha() {
// standard tiles
const tiles = document.querySelectorAll('.rc-imageselect-tile-wrapper img, .rc-image-tile-wrapper img');
// instruction node
const instr = document.querySelector('.rc-imageselect-instructions, .rc-imageselect-desc-no-canonical, .rc-imageselect-instructions div');
if (tiles && tiles.length && instr) {
return { type: 'recaptcha', tiles: Array.from(tiles), label: instr.innerText.trim() };
}
return null;
}
function findHcaptcha() {
const tiles = document.querySelectorAll('.task-image img, .image-grid img, .challenge-image img');
const instr = document.querySelector('.prompt-text, .caption-text');
if (tiles && tiles.length && instr) {
return { type: 'hcaptcha', tiles: Array.from(tiles), label: instr.innerText.trim() };
}
return null;
}
function highlight(el) { try { el.classList.add('cl_highlight'); } catch (e) {} }
function unhighlight(el) { try { el.classList.remove('cl_highlight'); } catch (e) {} }
function attachLearnHandler(img, label) {
const handler = function (ev) {
ev.stopPropagation(); ev.preventDefault();
const data = downscaleToDataUrl(img);
if (!data) return;
dbAdd(label, data);
updateInfo('Learned image for "' + label + '"');
unhighlight(img);
img.removeEventListener('click', handler);
renderDBList();
};
img.addEventListener('click', handler);
}
function tryAutoClick(img, label) {
const data = downscaleToDataUrl(img);
if (!data) return false;
const arr = dbGet(label) || [];
if (arr.includes(data)) {
// try to click container or img itself
const container = img.closest('button, td, div, a, span') || img;
try { container.click(); } catch (e) { try { img.click(); } catch (e2) {} }
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 {
unhighlight(img);
const ok = tryAutoClick(img, widget.label);
if (ok) matched++;
else {
highlight(img);
attachLearnHandler(img, widget.label);
}
} catch (e) {
console.error('process tile error', e);
}
}
if (matched) updateInfo('Auto-selected ' + matched + ' images for "' + widget.label + '"');
renderDBList();
}
// ---------- scanning loop ----------
let POLLER = null;
function scanOnce(force) {
try {
const r = findRecaptcha();
if (r) { processWidget(r); return; }
const h = findHcaptcha();
if (h) { processWidget(h); return; }
if (force) updateInfo('No image captcha found on page');
} catch (e) { console.error('scanOnce', e); }
}
function startPoller() {
stopPoller();
POLLER = setInterval(() => scanOnce(false), SETTINGS.pollInterval || DEFAULT_POLL);
updateInfo('Polling every ' + (SETTINGS.pollInterval || DEFAULT_POLL) + ' ms');
}
function stopPoller() {
if (POLLER) { clearInterval(POLLER); POLLER = null; updateInfo('Polling stopped'); }
}
function restartPoller() {
if (SETTINGS.autoStart) startPoller();
}
// ---------- init ----------
function init() {
ensureInfoBox();
ensurePanel();
renderDBList();
// auto-clear on start?
if (SETTINGS.autoClearOnStart) {
if (confirm('Auto-clear DB on start is enabled. Clear DB now?')) {
clearDB();
}
}
// auto start poller if enabled
if (SETTINGS.autoStart) startPoller();
// initial scan after short delay
setTimeout(() => scanOnce(true), 1200);
// refresh DB list periodically (in case learned)
setInterval(renderDBList, 3500);
}
// run
init();
})();