Last active
March 3, 2026 15:49
-
-
Save shuymn/2bcc1232ae6f764ddec4ffd20901b0b1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Gemini Force Pro | |
| // @namespace https://shuymn.me | |
| // @updateURL https://gist.githubusercontent.com/shuymn/2bcc1232ae6f764ddec4ffd20901b0b1/raw/gemini-force-pro.user.js | |
| // @downloadURL https://gist.githubusercontent.com/shuymn/2bcc1232ae6f764ddec4ffd20901b0b1/raw/gemini-force-pro.user.js | |
| // @version 0.4.0 | |
| // @description On /app (root) ensure Pro mode. Boost frequency for first 5s. If user interacts with mode UI, pause until URL changes. | |
| // @match https://gemini.google.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| const CFG = { | |
| // Selectors | |
| menuButtonSel: '[data-test-id="bard-mode-menu-button"]', | |
| proOptionSel: '[data-test-id="bard-mode-option-pro"]', | |
| anyOptionSel: '[data-test-id^="bard-mode-option-"]', | |
| anyItemSel: '[role="menuitemradio"], [data-test-id^="bard-mode-option-"]', | |
| proTextRe: /pro/i, | |
| // Frequency boost (first 5s) | |
| initialBoostMs: 5000, | |
| minIntervalBoostMs: 250, | |
| minIntervalNormalMs: 1500, | |
| mutationDebounceMsBoost: 80, | |
| mutationDebounceMsNormal: 250, | |
| // Action tuning | |
| openTimeoutMs: 2500, | |
| verifyTimeoutMs: 2500, | |
| // Debug | |
| debug: false, | |
| }; | |
| const log = (...a) => CFG.debug && console.log('[force-pro]', ...a); | |
| const t0 = Date.now(); | |
| const sleep = (ms) => new Promise(r => setTimeout(r, ms)); | |
| const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); | |
| const firstVisible = (list) => list.find(visible) || list[0] || null; | |
| // --- URL gating (per your requirement) --- | |
| function shouldHandleUrl(urlStr = location.href) { | |
| const u = new URL(urlStr, location.origin); | |
| return u.pathname === '/app' || u.pathname === '/app/'; | |
| } | |
| // --- Manual lock --- | |
| // If user interacts with mode UI on /app, we pause automation until URL changes. | |
| let manualLock = false; | |
| let lastHref = location.href; | |
| function setManualLock(reason) { | |
| if (!shouldHandleUrl()) return; | |
| if (!manualLock) log('manualLock ON:', reason); | |
| manualLock = true; | |
| } | |
| function clearManualLock(reason) { | |
| if (manualLock) log('manualLock OFF:', reason); | |
| manualLock = false; | |
| } | |
| function inBoostWindow() { | |
| return (Date.now() - t0) < CFG.initialBoostMs; | |
| } | |
| function minIntervalMs() { | |
| return inBoostWindow() ? CFG.minIntervalBoostMs : CFG.minIntervalNormalMs; | |
| } | |
| function mutationDebounceMs() { | |
| return inBoostWindow() ? CFG.mutationDebounceMsBoost : CFG.mutationDebounceMsNormal; | |
| } | |
| // --- “human-like click” (works in your environment) --- | |
| function humanClick(el) { | |
| if (!el) return false; | |
| try { | |
| el.scrollIntoView?.({ block: 'center', inline: 'center' }); | |
| el.focus?.(); | |
| const opts = { bubbles: true, cancelable: true, composed: true, view: window }; | |
| const p = (type) => el.dispatchEvent(new PointerEvent(type, { ...opts, pointerType: 'mouse', isPrimary: true, buttons: 1 })); | |
| const m = (type) => el.dispatchEvent(new MouseEvent(type, { ...opts, buttons: 1 })); | |
| p('pointerover'); p('pointerenter'); m('mouseover'); m('mouseenter'); | |
| p('pointerdown'); m('mousedown'); | |
| p('pointerup'); m('mouseup'); | |
| m('click'); | |
| return true; | |
| } catch { | |
| try { el.click(); return true; } catch { return false; } | |
| } | |
| } | |
| async function waitFor(fn, timeoutMs, pollMs = 120) { | |
| const start = performance.now(); | |
| while (performance.now() - start < timeoutMs) { | |
| const v = fn(); | |
| if (v) return v; | |
| await sleep(pollMs); | |
| } | |
| return null; | |
| } | |
| function isDisabled(el) { | |
| if (!el) return true; | |
| if (el.getAttribute('aria-disabled') === 'true') return true; | |
| if (el.hasAttribute('disabled')) return true; | |
| return /disabled|is-disabled/i.test(String(el.className || '')); | |
| } | |
| function closeMenu() { | |
| document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); | |
| } | |
| // --- Detect USER interactions with mode UI --- | |
| function isModeUiTarget(target) { | |
| if (!(target instanceof Element)) return false; | |
| // Direct hit | |
| if (target.matches(CFG.menuButtonSel) || target.matches(CFG.anyOptionSel)) return true; | |
| // Inside mode button / option | |
| if (target.closest(CFG.menuButtonSel) || target.closest(CFG.anyOptionSel)) return true; | |
| return false; | |
| } | |
| // Capture phase: only trusted events => user actions | |
| document.addEventListener('pointerdown', (e) => { | |
| if (!e.isTrusted) return; | |
| if (!shouldHandleUrl()) return; | |
| if (isModeUiTarget(e.target)) setManualLock('user pointerdown on mode UI'); | |
| }, true); | |
| document.addEventListener('click', (e) => { | |
| if (!e.isTrusted) return; | |
| if (!shouldHandleUrl()) return; | |
| if (isModeUiTarget(e.target)) setManualLock('user click on mode UI'); | |
| }, true); | |
| document.addEventListener('keydown', (e) => { | |
| if (!e.isTrusted) return; | |
| if (!shouldHandleUrl()) return; | |
| // Mode UI is often operated by Enter/Space | |
| if (e.key !== 'Enter' && e.key !== ' ') return; | |
| const active = document.activeElement; | |
| if (isModeUiTarget(active)) setManualLock(`user keydown(${e.key}) on mode UI`); | |
| }, true); | |
| // --- Core action: ensure Pro --- | |
| async function ensureProOnce(reason) { | |
| if (!shouldHandleUrl()) return { ok: false, stop: true, why: 'not /app root' }; | |
| if (manualLock) return { ok: false, stop: true, why: 'manualLock active' }; | |
| const btn = firstVisible(Array.from(document.querySelectorAll(CFG.menuButtonSel))); | |
| if (!btn) return { ok: false, stop: false, why: 'menu button not found yet' }; | |
| // Already Pro? (cheap shortcut) | |
| const label = (btn.textContent || '').toLowerCase(); | |
| if (label.includes('pro')) return { ok: true, stop: true, why: 'already pro (label)' }; | |
| // Open menu (2 tries) | |
| humanClick(btn); | |
| let items = await waitFor(() => { | |
| const list = Array.from(document.querySelectorAll(CFG.anyItemSel)); | |
| return list.length ? list : null; | |
| }, CFG.openTimeoutMs); | |
| if (!items) { | |
| humanClick(btn); | |
| items = await waitFor(() => { | |
| const list = Array.from(document.querySelectorAll(CFG.anyItemSel)); | |
| return list.length ? list : null; | |
| }, CFG.openTimeoutMs); | |
| } | |
| if (!items) { | |
| closeMenu(); | |
| return { ok: false, stop: false, why: 'menu did not open' }; | |
| } | |
| // Find Pro | |
| let pro = document.querySelector(CFG.proOptionSel); | |
| if (!pro) { | |
| pro = items.find(el => CFG.proTextRe.test((el.querySelector('.mode-title')?.textContent || el.textContent || '').trim())); | |
| } | |
| // No-op if unavailable | |
| if (!pro) { | |
| closeMenu(); | |
| return { ok: false, stop: true, why: 'pro not found (no-op)' }; | |
| } | |
| if (isDisabled(pro)) { | |
| closeMenu(); | |
| return { ok: false, stop: true, why: 'pro disabled (no-op)' }; | |
| } | |
| // Already checked? | |
| if (pro.getAttribute('aria-checked') === 'true' || pro.classList.contains('is-selected')) { | |
| closeMenu(); | |
| return { ok: true, stop: true, why: 'already pro (checked)' }; | |
| } | |
| // Select Pro | |
| humanClick(pro); | |
| // Verify (prefer aria-checked) | |
| const ok = await waitFor(() => { | |
| const p = document.querySelector(CFG.proOptionSel); | |
| if (p && p.getAttribute('aria-checked') === 'true') return true; | |
| const b = firstVisible(Array.from(document.querySelectorAll(CFG.menuButtonSel))); | |
| if (b && (b.textContent || '').toLowerCase().includes('pro')) return true; | |
| return null; | |
| }, CFG.verifyTimeoutMs, 150); | |
| closeMenu(); | |
| return { ok: !!ok, stop: !!ok, why: ok ? 'selected' : 'verify failed' }; | |
| } | |
| // --- Scheduler (boost + throttle) --- | |
| let lastRunAt = 0; | |
| let token = 0; | |
| let mutationTimer = null; | |
| function schedule(reason) { | |
| if (!shouldHandleUrl()) return; | |
| if (manualLock) { | |
| log('schedule skipped (manualLock)', reason); | |
| return; | |
| } | |
| const now = Date.now(); | |
| const minInt = minIntervalMs(); | |
| if (now - lastRunAt < minInt) { | |
| log('throttled', reason, { minInt }); | |
| return; | |
| } | |
| lastRunAt = now; | |
| const myToken = ++token; | |
| log('scheduled', reason, 'href=', location.href, { boost: inBoostWindow() }); | |
| (async () => { | |
| const base = inBoostWindow() ? 120 : 250; | |
| for (let i = 0; i < 6; i++) { | |
| if (myToken !== token) return; | |
| if (manualLock) return; // if user clicked during retries, stop immediately | |
| const r = await ensureProOnce(reason); | |
| log('attempt', i, r); | |
| if (r.stop) return; | |
| await sleep(base + i * (inBoostWindow() ? 120 : 300)); | |
| } | |
| })(); | |
| } | |
| // --- URL change detection => clear manualLock --- | |
| function onMaybeUrlChange(reason) { | |
| const href = location.href; | |
| if (href !== lastHref) { | |
| lastHref = href; | |
| clearManualLock(`url changed (${reason})`); | |
| } | |
| // If we arrived at /app root, try | |
| if (shouldHandleUrl()) schedule(`url-check:${reason}`); | |
| } | |
| function hookHistoryMethod(name) { | |
| const orig = history[name]; | |
| history[name] = function (...args) { | |
| const ret = orig.apply(this, args); | |
| onMaybeUrlChange(`history.${name}`); | |
| return ret; | |
| }; | |
| } | |
| hookHistoryMethod('pushState'); | |
| hookHistoryMethod('replaceState'); | |
| window.addEventListener('popstate', () => onMaybeUrlChange('popstate')); | |
| // Fallback: some navigations may not call history hooks (rare) => periodic href check (cheap) | |
| setInterval(() => onMaybeUrlChange('interval'), 500); | |
| // DOM rebuild watchdog (debounced) | |
| const mo = new MutationObserver(() => { | |
| if (!shouldHandleUrl()) return; | |
| if (manualLock) return; | |
| if (mutationTimer) return; | |
| mutationTimer = setTimeout(() => { | |
| mutationTimer = null; | |
| if (manualLock) return; | |
| if (document.querySelector(CFG.menuButtonSel)) schedule('mutation(button present)'); | |
| }, mutationDebounceMs()); | |
| }); | |
| mo.observe(document.documentElement, { childList: true, subtree: true }); | |
| // Initial run (no delay; boost window handles “early” timing) | |
| schedule('initial'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment