EP Hacks

EP Answer Helper + Anti-Snitch + Maths Solver + MCQ + Gaps + Auto-Skip

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         EP Hacks
// @namespace    http://tampermonkey.net/
// @version      6.0
// @description  EP Answer Helper + Anti-Snitch + Maths Solver + MCQ + Gaps + Auto-Skip
// @match        https://app.educationperfect.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  "use strict";

  // ── EXAM BYPASS (silent, always on) ───────────────────────────
  document.addEventListener('contextmenu', e => e.stopImmediatePropagation(), true);
  document.addEventListener('selectstart', e => e.stopImmediatePropagation(), true);
  document.addEventListener('copy',        e => e.stopImmediatePropagation(), true);

  // ── ANTI-SNITCH ───────────────────────────────────────────────
  let antiSnitchActive = true;

  function stopSnitch(e) {
    if (antiSnitchActive) e.stopImmediatePropagation();
  }

  try {
    Object.defineProperty(document, 'visibilityState', { get: () => antiSnitchActive ? 'visible' : 'hidden', configurable: true });
    Object.defineProperty(document, 'hidden',          { get: () => antiSnitchActive ? false : true, configurable: true });
  } catch(e) {}

  document.addEventListener('visibilitychange', stopSnitch, true);
  window.addEventListener('blur',     stopSnitch, true);
  window.addEventListener('focus',    stopSnitch, true);
  window.addEventListener('pagehide', stopSnitch, true);

  setInterval(() => {
    if (!antiSnitchActive) return;
    document.dispatchEvent(new MouseEvent('mousemove', {
      bubbles: true,
      clientX: 200 + Math.random() * 400,
      clientY: 200 + Math.random() * 300,
    }));
  }, 3000);

  // ── AUTO-SKIP ─────────────────────────────────────────────────
  let autoSkipActive = false;

  setInterval(() => {
    try {
      if (!autoSkipActive) return;
      const infoSlide = document.querySelector('.h-group.v-align-center.expanded-content.information.selected');
      if (!infoSlide) return;
      document.querySelectorAll('.continue.arrow.action-bar-button.v-group.ng-isolate-scope button').forEach(btn => btn.click());
    } catch(e) {}
  }, 100);

  // ── HELPERS ───────────────────────────────────────────────────
  function getScope() {
    try {
      if (!window.angular) return null;
      // Try lp-answer-input first (works for all question types incl. exams)
      const lpEl = document.querySelector('.lp-answer-input');
      if (lpEl) return angular.element(lpEl).scope();
      // Fallback to answer-text input
      const input = [...document.querySelectorAll('#answer-text')].find(el => el.tagName === 'INPUT');
      if (input) return angular.element(input).scope();
      return null;
    } catch(e) { return null; }
  }

  function getQuestion() {
    try {
      const scope = getScope();
      return scope?.self?.model?._currentQuestion || null;
    } catch(e) { return null; }
  }

  function parseArr(expr) {
    const match = expr?.match(/\[([^\]]+)\]/);
    if (!match) return null;
    return match[1].split(',').map(s => s.replace(/['"]/g, '').trim());
  }

  function resolveVars(vars, rng) {
    const resolved = { rng };
    vars?.forEach(v => {
      if (v.Name === 'rng') return;
      // Direct value — always resolve regardless of rng
      if (v.value !== null && v.value !== undefined && !v.expression) {
        resolved[v.Name] = String(v.value);
      } else if (v.expression?.includes('[rng]')) {
        const arr = parseArr(v.expression);
        if (arr) resolved[v.Name] = arr[rng];
      }
    });
    return resolved;
  }

  // Check question text for degree symbol requirement
  function needsDegreeSymbol() {
    try {
      const text = document.querySelector('.question-text, [class*="question-body"], .lp-answer-input')?.closest('[class*="question"], [class*="game"]')?.innerText || '';
      return /degree symbol|include.*°|°.*symbol/i.test(text);
    } catch(e) { return false; }
  }

  // ── ANSWER GETTERS ────────────────────────────────────────────

  // 1. Standard language answer
  function getStandardAnswer() {
    try {
      const q = getQuestion();
      if (!q) return null;
      const answer = q.validAnswers?.[0]?.outputString;
      if (!answer) return null;
      return { answer, prompt: q.specifiedDisplayName || null, type: 'standard' };
    } catch(e) { return null; }
  }

  // 2. MCQ answer
  function getMCQAnswer() {
    try {
      const q = getQuestion();
      if (!q || q.questionType !== 6) return null;
      const components = q.questionDef?.Components;
      if (!components?.length) return null;
      // Must have Options with Correct flag AND visible MCQ buttons on screen
      if (!components[0]?.Options?.some(o => o.Correct === 'true' || o.Correct === true)) return null;
      if (!document.querySelector('.mcq-preview-option')) return null;

      const vars = q.questionGeneratedVariables;
      const rng = vars?.find(v => v.Name === 'rng')?.value || 0;
      const resolved = resolveVars(vars, rng);

      const correct = components[0].Options.find(o => o.Correct === 'true');
      if (!correct) return null;

      const tmpl = correct.TextTemplate;
      // Pre-substitute all \var{} in template for use throughout
      const tmplResolved = tmpl.replace(/(?:\\\\|\\)?var\{(\w+)\}/g, (_, k) => resolved[k] !== undefined ? resolved[k] : k);
      const trigFn = tmpl.includes('sin') ? 'sin' : tmpl.includes('cos') ? 'cos' : tmpl.includes('tan') ? 'tan' : null;

      // Extract frac numerator/denominator from resolved template
      const fracParts = tmplResolved.match(/frac\{([^}]+(?:\{[^}]*\}[^}]*)*)\}\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/);
      const numPart = fracParts?.[1] || '';
      const denPart = fracParts?.[2] || '';

      // Check if numerator/denominator contains a plain number (not trig)
      // After resolution, a numeric part will just be a number like "5.9"
      const numPartIsNumber = /^[\d.]+$/.test(numPart.trim()) || (/^[\d.]+$/.test(numPart.replace(/[^0-9.]/g,''))) && !/(sin|cos|tan)/.test(numPart);
      const numPartHasVar = !/(sin|cos|tan)/.test(numPart);
      const denPartHasVar = !/(sin|cos|tan)/.test(denPart);

      // Get numeric values from parts
      const numVarVal = numPart.replace(/[^0-9.]/g,'') || null;
      const denVarVal = denPart.replace(/[^0-9.]/g,'') || null;

      // Extract simple {x}/{y} pattern
      const simpleFrac = tmpl.match(/\{(\w+)\}\/\{(\w+)\}/);
      const numVal = simpleFrac ? resolved[simpleFrac[1]] : null;
      const denVal = simpleFrac ? resolved[simpleFrac[2]] : null;

      // Highlight correct button
      const activeContainer = document.querySelector('.multi-choice-component');
      const buttons = activeContainer
        ? [...activeContainer.querySelectorAll('.mcq-preview-option')]
        : [...document.querySelectorAll('.mcq-preview-option')];

      // Clear all borders first
      document.querySelectorAll('.mcq-preview-option').forEach(btn => {
        btn.style.outline = '';
        btn.style.borderRadius = '';
      });

      let bestBtn = null;
      let bestScore = -1;

      buttons.forEach(btn => {
        const text = btn.textContent.toLowerCase().replace(/\s/g, '');
        let score = 0;

        // Trig function match
        if (trigFn) {
          if (text.includes(trigFn)) score += 5;
          else score -= 10;
        }

        // Number order — check if numeric value appears before or after trig function
        if (trigFn && (numVarVal || denVarVal)) {
          const numericVal = String(numVarVal || denVarVal);
          const fnIdx = text.indexOf(trigFn);
          const numIdx = text.indexOf(numericVal);
          if (fnIdx !== -1 && numIdx !== -1) {
            if (numPartHasVar) {
              // Number should be BEFORE trig fn (numerator)
              if (numIdx < fnIdx) score += 6;
              else score -= 6;
            } else {
              // Number should be AFTER trig fn (denominator)
              if (numIdx > fnIdx) score += 6;
              else score -= 6;
            }
          }
        }

        // Number order for {opp}/{hyp} style
        if (!numVarVal && numVal && denVal) {
          const ni = text.indexOf(String(numVal));
          const di = text.indexOf(String(denVal));
          if (ni !== -1 && di !== -1 && ni < di) score += 5;
        }

        // Text match — substitute all vars into template and match against button
        {
          // Use pre-resolved template
          let resolvedTmpl = tmplResolved.replace(/`/g, '').replace(/['"]/g, '').toLowerCase();
          Object.keys(resolved).forEach(k => {
            resolvedTmpl = resolvedTmpl.split('{' + k + '}').join(String(resolved[k]));
          });
          const cleanTmpl = resolvedTmpl.replace(/\\[a-zA-Z]+/g, '').replace(/[^a-z0-9]/g, '');
          const cleanText = text.replace(/[^a-z0-9]/g, '');
          if (cleanTmpl.length > 2 && cleanText.includes(cleanTmpl)) score += 8;
        }

        if (score > bestScore) { bestScore = score; bestBtn = btn; }
      });

      if (bestBtn && bestScore > 0) {
        bestBtn.style.outline = '3px solid #3fb950';
        bestBtn.style.borderRadius = '6px';
      }

      return {
        answer: 'Select option with green border',
        prompt: trigFn && numVarVal ? `${trigFn}(${numVarVal}/${denVarVal})` : tmpl.replace(/`/g,'').replace(/\\[a-zA-Z]+/g,'').slice(0,40),
        type: 'mcq',
      };
    } catch(e) { return null; }
  }

  // 3. Fill in the gaps
  function getFillGapsAnswer() {
    try {
      const q = getQuestion();
      if (!q) return null;
      const comp = q.questionDef?.Components?.find(c => c.ComponentTypeCode === 'FILL_IN_GAPS_COMPONENT');
      if (!comp?.Gaps?.length) return null;

      const answers = comp.Gaps.map((gap, i) => `Box ${i + 1}: ${gap.CorrectOptions?.[0] || '?'}`);
      return {
        answer: answers.join('  |  '),
        prompt: `Fill in ${comp.Gaps.length} gap${comp.Gaps.length > 1 ? 's' : ''}`,
        type: 'gaps',
      };
    } catch(e) { return null; }
  }

  // 4. Maths (generated variables)
  function getMathsAnswer() {
    try {
      const q = getQuestion();
      if (!q || q.questionType !== 6) return null;
      const vars = q.questionGeneratedVariables;
      if (!vars?.length) return null;
      // Skip if there are visible MCQ buttons (real MCQ question)
      if (document.querySelector('.mcq-preview-option')) return null;

      const rng = vars.find(v => v.Name === 'rng')?.value || 0;
      const resolved = {};
      vars.forEach(v => {
        if (v.Name === 'rng') { resolved.rng = rng; return; }
        if (v.value !== null && v.value !== undefined && !v.expression) {
          resolved[v.Name] = v.value;
        } else if (v.expression?.includes('[rng]')) {
          const arr = parseArr(v.expression);
          if (arr) resolved[v.Name] = parseFloat(arr[rng]);
        }
      });

      const answerVar = vars.find(v => v.Name === 'answer' || v.Name === 'ans' || v.Name === 'rans');
      if (!answerVar?.expression) return null;

      let expr = answerVar.expression
        .replace(/^fix\((.+),(\d+)\)$/, 'parseFloat(($1).toFixed($2))')
        .replace(/\bpi\b/g,    'Math.PI')
        .replace(/\bcos\b/g,   'Math.cos')
        .replace(/\bsin\b/g,   'Math.sin')
        .replace(/\btan\b/g,   'Math.tan')
        .replace(/\bsqrt\b/g,  'Math.sqrt')
        .replace(/\babs\b/g,   'Math.abs')
        .replace(/\bround\b/g, 'Math.round')
        .replace(/(?<!Math\.)\basin\b/g,  'Math.asin')
        .replace(/(?<!Math\.)\bacos\b/g,  'Math.acos')
        .replace(/(?<!Math\.)\batan\b/g,  'Math.atan');

      // Sort keys longest first to avoid partial replacements
      Object.keys(resolved).sort((a, b) => b.length - a.length).forEach(k => {
        expr = expr.replaceAll(k, String(resolved[k]));
      });

      const answer = eval(expr);
      if (isNaN(answer) || !isFinite(answer)) return null;

      // Check if degrees symbol needed
      const deg = needsDegreeSymbol() ? '°' : '';

      return {
        answer: String(answer) + deg,
        prompt: `🧮 ${answerVar.expression}`,
        type: 'maths',
      };
    } catch(e) { return null; }
  }

  function getAnswer() {
    return getStandardAnswer() || getMCQAnswer() || getFillGapsAnswer() || getMathsAnswer();
  }

  // ── BUILD UI ──────────────────────────────────────────────────
  function buildUI() {
    document.getElementById('ep-hacks-panel')?.remove();
    document.getElementById('ep-hacks-reopen')?.remove();

    const reopenBtn = document.createElement('div');
    reopenBtn.id = 'ep-hacks-reopen';
    reopenBtn.style.cssText = `
      position:fixed;top:16px;right:16px;z-index:999999;
      width:32px;height:32px;border-radius:50%;
      background:#58a6ff;color:#0f1117;
      font-size:18px;font-weight:700;
      display:none;align-items:center;justify-content:center;
      cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,0.4);
    `;
    reopenBtn.textContent = '⚡';
    document.body.appendChild(reopenBtn);

    const panel = document.createElement('div');
    panel.id = 'ep-hacks-panel';
    panel.style.cssText = `
      position:fixed;top:16px;right:16px;z-index:999999;
      background:#0f1117;color:#e2e8f0;
      border:1px solid #30363d;border-radius:12px;
      padding:16px 20px;font-family:'Fira Code',monospace;
      font-size:13px;min-width:240px;
      box-shadow:0 8px 32px rgba(0,0,0,0.6);
    `;
    panel.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;">
        <span style="font-size:14px;font-weight:700;color:#58a6ff;">⚡ EP Hacks</span>
        <button id="ep-hacks-close" style="background:none;border:none;color:#8b949e;cursor:pointer;font-size:18px;padding:0;line-height:1;">×</button>
      </div>

      <div style="margin-bottom:14px;">
        <div style="font-size:10px;color:#8b949e;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;">Answer</div>
        <div id="ep-hacks-prompt" style="font-size:11px;color:#484f58;margin-bottom:4px;">—</div>
        <div id="ep-hacks-answer" style="font-size:22px;font-weight:700;color:#3fb950;min-height:28px;letter-spacing:0.02em;cursor:pointer;" title="Click to copy">—</div>
        <div id="ep-hacks-type" style="font-size:10px;color:#484f58;margin-top:4px;"></div>
      </div>

      <div style="height:1px;background:#21262d;margin-bottom:14px;"></div>

      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
        <div>
          <div style="font-size:12px;font-weight:600;color:#e2e8f0;">Anti-Snitch</div>
          <div style="font-size:10px;color:#8b949e;">Hides tab switching</div>
        </div>
        <div id="ep-snitch-toggle" style="width:40px;height:22px;border-radius:11px;background:#238636;cursor:pointer;position:relative;transition:background 0.2s;">
          <div id="ep-snitch-knob" style="position:absolute;top:3px;left:21px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left 0.2s;"></div>
        </div>
      </div>

      <div style="display:flex;justify-content:space-between;align-items:center;">
        <div>
          <div style="font-size:12px;font-weight:600;color:#e2e8f0;">Auto-Skip Info</div>
          <div style="font-size:10px;color:#8b949e;">Skips learning slides</div>
        </div>
        <div id="ep-skip-toggle" style="width:40px;height:22px;border-radius:11px;background:#484f58;cursor:pointer;position:relative;transition:background 0.2s;">
          <div id="ep-skip-knob" style="position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left 0.2s;"></div>
        </div>
      </div>
    `;
    document.body.appendChild(panel);

    // Close / reopen
    document.getElementById('ep-hacks-close').addEventListener('click', () => {
      panel.style.display = 'none';
      reopenBtn.style.display = 'flex';
    });
    reopenBtn.addEventListener('click', () => {
      panel.style.display = '';
      reopenBtn.style.display = 'none';
    });

    // Anti-snitch toggle
    let snitchOn = true;
    document.getElementById('ep-snitch-toggle').addEventListener('click', () => {
      snitchOn = !snitchOn;
      antiSnitchActive = snitchOn;
      document.getElementById('ep-snitch-toggle').style.background = snitchOn ? '#238636' : '#484f58';
      document.getElementById('ep-snitch-knob').style.left = snitchOn ? '21px' : '3px';
    });

    // Auto-skip toggle
    document.getElementById('ep-skip-toggle').addEventListener('click', () => {
      autoSkipActive = !autoSkipActive;
      document.getElementById('ep-skip-toggle').style.background = autoSkipActive ? '#238636' : '#484f58';
      document.getElementById('ep-skip-knob').style.left = autoSkipActive ? '21px' : '3px';
    });
  }

  // ── ANSWER LOOP ───────────────────────────────────────────────
  function startAnswerLoop() {
    let lastAnswer = null;

    setInterval(() => {
      const data = getAnswer();
      if (!data?.answer || data.answer === lastAnswer) return;
      lastAnswer = data.answer;

      const promptEl = document.getElementById('ep-hacks-prompt');
      const answerEl = document.getElementById('ep-hacks-answer');
      const typeEl   = document.getElementById('ep-hacks-type');

      if (promptEl) promptEl.textContent = data.prompt || '—';

      if (answerEl) {
        if (data.type === 'gaps') {
          answerEl.innerHTML = data.answer.split('  |  ')
            .map(a => `<div style="font-size:15px;margin-bottom:4px;">${a}</div>`)
            .join('');
        } else {
          answerEl.textContent = data.answer;
        }

        // Auto-copy for non-MCQ, non-gaps
        if (data.type !== 'mcq' && data.type !== 'gaps') {
          navigator.clipboard.writeText(data.answer).catch(() => {});
        }

        // Click to copy
        answerEl.onclick = () => {
          const text = data.type === 'gaps'
            ? data.answer.replace(/  \|  /g, ', ')
            : data.answer;
          navigator.clipboard.writeText(text).then(() => {
            const orig = answerEl.style.color;
            answerEl.style.color = '#58a6ff';
            setTimeout(() => answerEl.style.color = orig, 500);
          }).catch(() => {});
        };
      }

      if (typeEl) typeEl.textContent =
        data.type === 'maths'  ? '🧮 Maths' :
        data.type === 'mcq'    ? '🔵 Multiple Choice' :
        data.type === 'gaps'   ? '✏️ Fill in Gaps' :
        '📝 Language';

    }, 500);
  }

  // ── INIT ──────────────────────────────────────────────────────
  // Wait for Angular to be ready
  const init = setInterval(() => {
    if (!window.angular) return;
    const lpEl = document.querySelector('.lp-answer-input');
    const input = [...document.querySelectorAll('#answer-text')].find(el => el.tagName === 'INPUT');
    if (!lpEl && !input) return;
    clearInterval(init);
    buildUI();
    startAnswerLoop();
  }, 300);

})();