TEC Voice Control

Controle por voz para responder e navegar questões no TEC Concursos.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         TEC Voice Control
// @namespace    https://www.tecconcursos.com.br/
// @version      1.0.2
// @description  Controle por voz para responder e navegar questões no TEC Concursos.
// @author       Bruno Camargo
// @license      MIT
// @homepageURL  https://github.com/eusoubrunocamargo/tec-voice-control
// @supportURL   https://github.com/eusoubrunocamargo/tec-voice-control/issues
// @match        https://www.tecconcursos.com.br/*
// @match        https://tecconcursos.com.br/*
// @match        *://*.tecconcursos.com.br/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  if (!location.hostname.endsWith('tecconcursos.com.br')) {
    return;
  }

  const CONFIG = {
    language: 'pt-BR',
    dedupeMs: 1200,
    enableTTS: false,
    showToast: true,
    autoStart: false,
    useMicPreflight: true,
    restartBaseDelayMs: 400,
    restartMaxDelayMs: 6000,
    maxSequentialNetworkErrors: 1,
    networkCooldownMs: 30000,
    scrollStepPx: 420,
    commandAliases: {
      certo: ['certo', 'c'],
      errado: ['errado', 'e'],
      responder: ['responder', 'resolver', 'confirmar'],
      professor: ['professor', 'comentario', 'comentario da questao'],
      comunidade: ['comunidade', 'forum', 'discussao'],
      proxima: ['proxima', 'proximo', 'avancar', 'seguinte'],
      anterior: ['anterior', 'voltar', 'retornar'],
      descer: ['descer', 'scroll down', 'rolar para baixo', 'abaixo'],
      subir: ['subir', 'scroll up', 'rolar para cima', 'acima'],
      fechar: ['fechar', 'voltar questao', 'voltar para questao', 'fechar comentario'],
    },
    selectors: {
      certo: ['#alternativa-0'],
      errado: ['#alternativa-1'],
      responder: ['.botao-resolver:not([disabled])'],
      responderAny: ['.botao-resolver'],
      professor: [
        "button[ng-click*=\"abrirComplemento('comentario')\"]",
        "button[aria-label*='Comentário']",
        "button[aria-label*='Comentário da questao']",
      ],
      comunidade: [
        "button[ng-click*=\"abrirComplemento('discussao')\"]",
        "button[aria-label*='Fórum']",
      ],
      proxima: [
        '.questao-navegacao-botao-proxima',
        "button[aria-label='Próxima questão']",
        "button[ng-click*='questaoSeguinte']",
      ],
      anterior: [
        '.questao-navegacao-botao-anterior',
        "button[aria-label='Questão anterior']",
        "button[ng-click*='questaoAnterior']",
      ],
      fechar: [
        '.botao-fechar-complemento',
        "button[ng-click*='fecharComplemento']",
        "button[aria-label*='Fechar']",
      ],
    },
  };

  const state = {
    isListening: false,
    recognition: null,
    manuallyStopped: false,
    permissionDenied: false,
    hasMicAccess: false,
    networkErrorCount: 0,
    networkCooldownUntil: 0,
    restartAttempts: 0,
    restartTimer: null,
    lastCommand: null,
    lastCommandAt: 0,
    ui: {
      panel: null,
      status: null,
      last: null,
      toggle: null,
    },
  };

  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;

  function normalizeText(text) {
    return String(text || '')
      .toLowerCase()
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .replace(/[^a-z0-9\s]/g, ' ')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function mapToCommand(transcript) {
    const normalized = normalizeText(transcript);
    if (!normalized) return null;

    const words = normalized.split(' ');
    const joined = normalized;

    for (const [command, aliases] of Object.entries(CONFIG.commandAliases)) {
      for (const alias of aliases) {
        const nAlias = normalizeText(alias);
        if (!nAlias) continue;

        if (joined === nAlias || words.includes(nAlias)) {
          return command;
        }
      }
    }

    return null;
  }

  function shouldDedupe(command) {
    const now = Date.now();
    const isDuplicate = state.lastCommand === command && now - state.lastCommandAt < CONFIG.dedupeMs;
    if (isDuplicate) {
      return true;
    }
    state.lastCommand = command;
    state.lastCommandAt = now;
    return false;
  }

  function findElement(selectors) {
    for (const selector of selectors) {
      const element = document.querySelector(selector);
      if (element) return element;
    }
    return null;
  }

  function fireKeyboard(key, keyCode) {
    const opts = {
      key,
      code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
      keyCode,
      which: keyCode,
      bubbles: true,
      cancelable: true,
    };

    const down = new KeyboardEvent('keydown', opts);
    const press = new KeyboardEvent('keypress', opts);
    const up = new KeyboardEvent('keyup', opts);

    const targets = [];
    if (document.activeElement) targets.push(document.activeElement);
    targets.push(document);
    targets.push(window);

    for (const target of targets) {
      try {
        target.dispatchEvent(down);
        target.dispatchEvent(press);
        target.dispatchEvent(up);
      } catch (err) {
        console.warn('[TEC Voice] keyboard dispatch failed:', err);
      }
    }
  }

  function speak(message) {
    if (!CONFIG.enableTTS || !('speechSynthesis' in window)) {
      return;
    }

    const utterance = new SpeechSynthesisUtterance(message);
    utterance.lang = CONFIG.language;
    window.speechSynthesis.cancel();
    window.speechSynthesis.speak(utterance);
  }

  function showToast(message, isError) {
    if (!CONFIG.showToast) {
      return;
    }

    const toast = document.createElement('div');
    toast.textContent = message;
    toast.style.position = 'fixed';
    toast.style.bottom = '88px';
    toast.style.right = '16px';
    toast.style.zIndex = '999999';
    toast.style.padding = '8px 10px';
    toast.style.borderRadius = '8px';
    toast.style.background = isError ? 'rgba(181,33,24,0.92)' : 'rgba(17,24,39,0.92)';
    toast.style.color = '#fff';
    toast.style.fontSize = '12px';
    toast.style.fontFamily = 'system-ui, -apple-system, sans-serif';
    toast.style.boxShadow = '0 8px 24px rgba(0,0,0,0.25)';
    toast.style.opacity = '0';
    toast.style.transition = 'opacity 120ms ease';

    document.body.appendChild(toast);
    requestAnimationFrame(() => {
      toast.style.opacity = '1';
    });

    setTimeout(() => {
      toast.style.opacity = '0';
      setTimeout(() => {
        toast.remove();
      }, 160);
    }, 1300);
  }

  function report(message, isError) {
    updateLastAction(message);
    showToast(message, Boolean(isError));
    if (!isError) {
      speak(message);
    }
    if (isError) {
      console.warn('[TEC Voice]', message);
    } else {
      console.info('[TEC Voice]', message);
    }
  }

  function clickIfFound(selectors) {
    const el = findElement(selectors);
    if (!el) return false;
    if (el.hasAttribute('disabled')) {
      return false;
    }

    if (typeof el.focus === 'function') {
      el.focus({ preventScroll: true });
    }

    const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
    const up = new MouseEvent('mouseup', { bubbles: true, cancelable: true });
    const click = new MouseEvent('click', { bubbles: true, cancelable: true });

    el.dispatchEvent(down);
    el.dispatchEvent(up);
    el.dispatchEvent(click);
    return true;
  }

  function getScrollContainer() {
    const candidates = [
      '.questao-complementos-comentario',
      '.questao-complementos-comentario-pai',
      '.questao-complementos-comentario-conteudo-texto',
    ];

    for (const selector of candidates) {
      const el = document.querySelector(selector);
      if (el && el.scrollHeight > el.clientHeight) {
        return el;
      }
    }

    return window;
  }

  function smoothScrollBy(delta) {
    const container = getScrollContainer();
    if (container === window) {
      window.scrollBy({ top: delta, behavior: 'smooth' });
      return;
    }

    container.scrollBy({ top: delta, behavior: 'smooth' });
  }

  function executeCommand(command) {
    switch (command) {
      case 'certo': {
        if (clickIfFound(CONFIG.selectors.certo)) {
          report('Certo marcado.');
          return;
        }
        fireKeyboard('c', 67);
        report('Certo enviado (atalho).');
        return;
      }
      case 'errado': {
        if (clickIfFound(CONFIG.selectors.errado)) {
          report('Errado marcado.');
          return;
        }
        fireKeyboard('e', 69);
        report('Errado enviado (atalho).');
        return;
      }
      case 'responder': {
        if (clickIfFound(CONFIG.selectors.responder)) {
          report('Questão resolvida.');
          return;
        }

        const button = findElement(CONFIG.selectors.responderAny);
        if (button && button.hasAttribute('disabled')) {
          report('Selecione uma alternativa primeiro.', true);
          return;
        }

        fireKeyboard('Enter', 13);
        report('Resolver enviado (atalho).');
        return;
      }
      case 'professor': {
        if (clickIfFound(CONFIG.selectors.professor)) {
          report('Abrindo comentário do professor.');
        } else {
          report('Botão de comentário não encontrado.', true);
        }
        return;
      }
      case 'comunidade': {
        if (clickIfFound(CONFIG.selectors.comunidade)) {
          report('Abrindo comunidade.');
        } else {
          report('Botão de comunidade não encontrado.', true);
        }
        return;
      }
      case 'proxima': {
        if (clickIfFound(CONFIG.selectors.proxima)) {
          report('Próxima questão.');
          return;
        }
        fireKeyboard('ArrowRight', 39);
        report('Próxima enviada (atalho).');
        return;
      }
      case 'anterior': {
        if (clickIfFound(CONFIG.selectors.anterior)) {
          report('Questão anterior.');
          return;
        }
        fireKeyboard('ArrowLeft', 37);
        report('Anterior enviada (atalho).');
        return;
      }
      case 'descer': {
        smoothScrollBy(CONFIG.scrollStepPx);
        report('Rolando para baixo.');
        return;
      }
      case 'subir': {
        smoothScrollBy(-CONFIG.scrollStepPx);
        report('Rolando para cima.');
        return;
      }
      case 'fechar': {
        if (clickIfFound(CONFIG.selectors.fechar)) {
          report('Comentário fechado.');
          return;
        }
        fireKeyboard('Escape', 27);
        report('Fechar enviado (Esc).');
        return;
      }
      default:
        return;
    }
  }

  function scheduleRestart() {
    if (state.permissionDenied) {
      return;
    }

    if (Date.now() < state.networkCooldownUntil) {
      return;
    }

    clearTimeout(state.restartTimer);
    const delay = Math.min(
      CONFIG.restartBaseDelayMs * Math.pow(1.6, state.restartAttempts),
      CONFIG.restartMaxDelayMs
    );

    state.restartAttempts += 1;

    state.restartTimer = setTimeout(() => {
      if (!state.manuallyStopped) {
        startListening({ fromRestart: true });
      }
    }, delay);
  }

  async function ensureMicrophoneAccess() {
    if (!CONFIG.useMicPreflight) {
      return true;
    }

    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      return true;
    }

    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      stream.getTracks().forEach((track) => track.stop());
      state.hasMicAccess = true;
      state.permissionDenied = false;
      return true;
    } catch (err) {
      const errorName = err && err.name ? err.name : 'Erro de microfone';
      state.permissionDenied = true;
      state.hasMicAccess = false;
      report(`Microfone bloqueado (${errorName}).`, true);
      return false;
    }
  }

  function getNetworkCooldownSecondsLeft() {
    const ms = state.networkCooldownUntil - Date.now();
    return Math.max(0, Math.ceil(ms / 1000));
  }

  function onResult(event) {
    for (let i = event.resultIndex; i < event.results.length; i += 1) {
      const result = event.results[i];
      if (!result || !result.isFinal) continue;

      const transcript = result[0] ? result[0].transcript : '';
      const command = mapToCommand(transcript);

      if (!command) {
        continue;
      }

      if (shouldDedupe(command)) {
        continue;
      }

      executeCommand(command);
    }
  }

  function stopListening(manual) {
    if (!state.recognition) {
      state.isListening = false;
      state.manuallyStopped = Boolean(manual);
      updateUi();
      return;
    }

    state.manuallyStopped = Boolean(manual);

    try {
      state.recognition.stop();
    } catch (err) {
      console.warn('[TEC Voice] stop failed:', err);
    }

    state.isListening = false;
    updateUi();
  }

  function createRecognition() {
    if (!SpeechRecognition) {
      return null;
    }

    const recognition = new SpeechRecognition();
    recognition.lang = CONFIG.language;
    recognition.continuous = true;
    recognition.interimResults = false;
    recognition.maxAlternatives = 3;

    recognition.onstart = () => {
      state.isListening = true;
      state.restartAttempts = 0;
      updateUi();
      updateLastAction('Escuta iniciada.');
    };

    recognition.onresult = onResult;

    recognition.onerror = (event) => {
      const error = event && event.error ? event.error : 'erro desconhecido';
      updateLastAction(`Erro: ${error}`);
      console.warn('[TEC Voice] recognition error:', error);

      if (error === 'not-allowed' || error === 'service-not-allowed') {
        state.permissionDenied = true;
        report('Permissão de microfone negada no navegador.', true);
        stopListening(true);
        return;
      }

      if (error === 'network') {
        state.networkErrorCount += 1;

        if (state.networkErrorCount >= CONFIG.maxSequentialNetworkErrors) {
          state.networkCooldownUntil = Date.now() + CONFIG.networkCooldownMs;
          state.manuallyStopped = true;
          report(
            `Erro de rede recorrente. Pausado por ${Math.ceil(
              CONFIG.networkCooldownMs / 1000
            )}s. Clique em Iniciar para tentar novamente.`,
            true
          );
          stopListening(true);
        }
      }
    };

    recognition.onend = () => {
      state.isListening = false;
      updateUi();

      if (!state.manuallyStopped) {
        scheduleRestart();
      }
    };

    return recognition;
  }

  async function startListening(options = {}) {
    const fromRestart = Boolean(options.fromRestart);
    const manualTrigger = Boolean(options.manualTrigger);

    if (!SpeechRecognition) {
      report('Seu navegador não suporta reconhecimento de voz.', true);
      return;
    }

    if (state.permissionDenied) {
      report('Microfone bloqueado. Libere a permissão e clique em Iniciar.', true);
      return;
    }

    if (!manualTrigger && Date.now() < state.networkCooldownUntil) {
      const secondsLeft = getNetworkCooldownSecondsLeft();
      report(`Aguardando rede estabilizar (${secondsLeft}s).`, true);
      return;
    }

    if (manualTrigger) {
      state.networkErrorCount = 0;
      state.networkCooldownUntil = 0;
    }

    if ((!fromRestart || !state.hasMicAccess) && !(await ensureMicrophoneAccess())) {
      return;
    }

    clearTimeout(state.restartTimer);
    state.manuallyStopped = false;

    if (!state.recognition) {
      state.recognition = createRecognition();
    }

    if (!state.recognition || state.isListening) {
      return;
    }

    try {
      state.recognition.start();
    } catch (err) {
      console.warn('[TEC Voice] start failed:', err);
      scheduleRestart();
    }
  }

  function toggleListening() {
    if (state.isListening) {
      stopListening(true);
      updateLastAction('Escuta parada.');
      return;
    }

    if (state.permissionDenied) {
      state.permissionDenied = false;
    }

    startListening({ manualTrigger: true });
  }

  function updateStatus(text) {
    if (state.ui.status) {
      state.ui.status.textContent = text;
    }
  }

  function updateLastAction(text) {
    if (state.ui.last) {
      state.ui.last.textContent = `Último: ${text}`;
    }
  }

  function updateUi() {
    const status = state.isListening ? 'Ouvindo' : 'Parado';
    updateStatus(`Status: ${status}`);
    if (state.ui.toggle) {
      state.ui.toggle.textContent = state.isListening ? 'Parar' : 'Iniciar';
      state.ui.toggle.style.background = state.isListening ? '#b91c1c' : '#065f46';
    }
  }

  function buildPanel() {
    const panel = document.createElement('div');
    panel.id = 'tec-voice-panel';
    panel.style.position = 'fixed';
    panel.style.right = '12px';
    panel.style.bottom = '12px';
    panel.style.zIndex = '999999';
    panel.style.background = 'rgba(17, 24, 39, 0.96)';
    panel.style.color = '#ffffff';
    panel.style.padding = '10px';
    panel.style.borderRadius = '10px';
    panel.style.fontFamily = 'system-ui, -apple-system, sans-serif';
    panel.style.fontSize = '12px';
    panel.style.width = '188px';
    panel.style.boxShadow = '0 10px 32px rgba(0, 0, 0, 0.35)';

    const title = document.createElement('div');
    title.textContent = 'TEC Voice';
    title.style.fontWeight = '700';
    title.style.marginBottom = '6px';

    const status = document.createElement('div');
    status.textContent = 'Status: Parado';
    status.style.marginBottom = '4px';

    const last = document.createElement('div');
    last.textContent = 'Último: aguardando';
    last.style.marginBottom = '8px';
    last.style.opacity = '0.9';

    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = 'Iniciar';
    button.style.width = '100%';
    button.style.border = '0';
    button.style.borderRadius = '8px';
    button.style.padding = '7px 8px';
    button.style.color = '#fff';
    button.style.cursor = 'pointer';
    button.style.background = '#065f46';
    button.addEventListener('click', toggleListening);

    const hint = document.createElement('div');
    hint.textContent = 'Clique em Iniciar para liberar microfone.';
    hint.style.marginTop = '6px';
    hint.style.opacity = '0.8';

    const shortcut = document.createElement('div');
    shortcut.textContent = 'Atalho: Ctrl+Shift+V';
    shortcut.style.marginTop = '4px';
    shortcut.style.opacity = '0.8';

    panel.appendChild(title);
    panel.appendChild(status);
    panel.appendChild(last);
    panel.appendChild(button);
    panel.appendChild(hint);
    panel.appendChild(shortcut);

    state.ui.panel = panel;
    state.ui.status = status;
    state.ui.last = last;
    state.ui.toggle = button;

    document.body.appendChild(panel);
    updateUi();
  }

  function installHotkey() {
    document.addEventListener('keydown', (event) => {
      if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'v') {
        event.preventDefault();
        toggleListening();
      }
    });
  }

  function installObserver() {
    const observer = new MutationObserver(() => {
      if (!document.body.contains(state.ui.panel)) {
        buildPanel();
      }
    });

    observer.observe(document.body, { childList: true, subtree: true });
  }

  function init() {
    buildPanel();
    installHotkey();
    installObserver();
    if (CONFIG.autoStart) {
      startListening();
    } else {
      updateLastAction('Pronto. Clique em Iniciar para ativar o microfone.');
    }
  }

  init();
})();