TEC Voice Control

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();