Greasy Fork is available in English.
Controle por voz para responder e navegar questões no TEC Concursos.
// ==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();
})();