PTIT Code Helper Lite

Hỗ trợ thực hành PTIT Code

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         PTIT Code Helper Lite
// @version      1.2.0
// @description  Hỗ trợ thực hành PTIT Code
// @author       An Vũ
// @match        *://code.ptit.edu.vn/student/question/*
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-body
// @connect      localhost
// @namespace https://anvu.web.app
// ==/UserScript==

(function () {
    'use strict';
    console.log("Đã nạp PTIT Code Helper. Tải bản extension để trải nghiệm đầy đủ tính năng.");
    const TABLE_REGEX = /(?:\b(?:input|output|in|out|giải thích)\b|\.(?:inp?|out?))/i;
    const NBSP_REGEX = /[\u00A0\u1680\u180E\u2000-\u200B\u202F\u205F\u3000\uFEFF]/g;
    const ZERO_WIDTH_REGEX = /[\u200B-\u200D\uFEFF]/g;
    const SELECTOR_TITLE = '.submit__nav p span a.link--red';
    const SELECTOR_CONTENT = '.submit__des';
    const GITHUB_REPOS = { CPP: 'anvu17/PTIT-C-CPP', C: 'anvu17/PTIT-C-CPP', CTDL: 'anvu17/PTIT-DSA', DSA: 'anvu17/PTIT-DSA' };
    const PROBLEM_ID = location.pathname.split('/').pop();
    const I = {
        copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
        download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
        cph: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.15 2.587L18.21.22a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"/></svg>',
        github: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>',
        check: '<svg viewBox="0 0 24 24" fill="none" stroke="#28a745" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
        code: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
        paste: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>',
        upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
        trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
        loading: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>'
    };

    const CSS = ':root{--pch-red:#b71c1c;--pch-red-hover:#d32f2f;--pch-green:#28a745}' +
        '.pch-title-actions{display:inline-flex;align-items:center;gap:6px;margin-left:12px;vertical-align:middle}' +
        '.pch-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;color:#5f6368;cursor:pointer;border-radius:4px;border:1px solid #dadce0;background:#fff;transition:all .15s ease}' +
        '.pch-icon-btn:hover{color:var(--pch-red);border-color:var(--pch-red);background:#fff5f5}' +
        '.pch-icon-btn svg{width:16px;height:16px;stroke-width:2}' +
        '.pch-copy-btn{position:absolute;top:4px;right:4px;width:24px;height:24px;border-radius:4px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:#adb5bd;cursor:pointer;transition:all .2s}' +
        '.pch-copy-btn:hover{color:var(--pch-red);background:rgba(183,28,28,.08)}' +
        '.pch-copy-btn svg{width:14px;height:14px}' +
        '.pch-helper-box{background:#fff;border:1px solid #dcdcdc;border-radius:4px;box-shadow:0 4px 10px rgba(0,0,0,.05);margin:20px 0 25px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;overflow:hidden}' +
        '.pch-box-header{padding:8px 15px;background:#f8f9fa;border-bottom:1px solid #dcdcdc;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}' +
        '.pch-box-title{font-weight:700;color:var(--pch-red);font-size:14px;display:flex;align-items:center;gap:8px;white-space:nowrap;text-decoration:none}' +
        '.pch-toolbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}' +
        '.pch-btn{display:inline-flex;align-items:center;justify-content:center;font-weight:500;padding:5px 12px;font-size:13px;line-height:1.5;border-radius:4px;transition:all .15s;cursor:pointer;background:#fff;border:1px solid #ced4da;color:#495057;gap:6px;white-space:nowrap;flex-shrink:0}' +
        '.pch-btn svg{width:14px;height:14px;flex-shrink:0}' +
        '.pch-btn:hover{background:#e9ecef;border-color:#adb5bd}' +
        '.pch-btn-red{color:#fff;background:var(--pch-red);border-color:var(--pch-red)}' +
        '.pch-btn-red:hover{background:var(--pch-red-hover);border-color:#a71d2a}' +
        '#pch-ace-editor{width:100%;height:500px;border-bottom:1px solid #dcdcdc;font-size:14px}' +
        '.pch-drag-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(183,28,28,.7);z-index:999999;display:none;align-items:center;justify-content:center;pointer-events:all}' +
        '.pch-drag-overlay.active{display:flex}' +
        '.pch-drag-content{display:flex;flex-direction:column;align-items:center;gap:20px;color:#fff;pointer-events:none}' +
        '.pch-drag-content svg{width:80px;height:80px;stroke-width:1.5;animation:pchBounce 1s ease-in-out infinite}' +
        '.pch-drag-text{font-size:24px;font-weight:600;text-shadow:0 2px 4px rgba(0,0,0,.3)}' +
        '@keyframes pchBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}}';

    const getTextFromElement = el => {
        if (!el) return '';
        return el.cloneNode(true).innerText.replace(NBSP_REGEX, ' ').split('\n').map(l => l.trim()).filter(l => l.length > 0).join('\n');
    };

    const getProblemDescription = () => {
        const c = document.querySelector(SELECTOR_CONTENT);
        if (!c) return '';
        const cl = c.cloneNode(true);
        cl.querySelectorAll('table').forEach(t => {
            if (t.rows && t.rows.length && [...t.rows[0].querySelectorAll('th,td')].some(c => TABLE_REGEX.test(c.innerText))) t.remove();
        });
        cl.querySelectorAll('p,b,strong').forEach(p => { if (p.innerText.trim() === 'Ví dụ:') p.remove(); });
        return cl.innerText.replace(NBSP_REGEX, ' ').replace(ZERO_WIDTH_REGEX, '').split('\n').map(l => l.trimEnd()).filter((l, i, a) => l !== '' || (i > 0 && a[i - 1] !== '')).join('\n').replace(/\n{2,}/g, '\n\n').trim();
    };

    const getProblemInfo = () => {
        const el = document.querySelector(SELECTOR_TITLE);
        const nameRaw = el ? el.textContent : PROBLEM_ID;
        const code = PROBLEM_ID;
        return { code, name: code, nameRaw, url: location.href };
    };

    const getTests = () => [...document.querySelectorAll('table')].reduce((acc, t) => {
        if (!t.rows || !t.rows.length) return acc;
        const cols = [...t.rows[0].querySelectorAll('th,td')].reduce((a, c) => { if (TABLE_REGEX.test(c.innerText)) a.push(c.cellIndex); return a; }, []);
        if (cols.length >= 2) {
            const max = Math.max(...cols.slice(0, 2));
            [...t.querySelectorAll('tr')].slice(1).forEach(r => {
                if (r.cells.length > max && !TABLE_REGEX.test(r.cells[cols[0]].innerText)) {
                    const inp = getTextFromElement(r.cells[cols[0]]), out = getTextFromElement(r.cells[cols[1]]);
                    const exp = cols[2] !== undefined ? getTextFromElement(r.cells[cols[2]]) : '';
                    if (inp || out) acc.push({ input: inp, output: out, explanation: exp });
                }
            });
        }
        return acc;
    }, []);

    const generateProblemText = () => {
        const info = getProblemInfo(), tests = getTests();
        const parts = ['PROBLEM: ' + PROBLEM_ID + ' - ' + info.nameRaw, 'URL: ' + info.url + '\n', 'DESCRIPTION:', getProblemDescription() + '\n'];
        tests.forEach((t, i) => {
            const tp = ['[TEST ' + (i + 1) + ']', '--- Input ---\n' + t.input];
            if (t.output) tp.push('--- Output ---\n' + t.output);
            if (t.explanation) tp.push('--- Explanation ---\n' + t.explanation);
            parts.push(tp.join('\n') + '\n');
        });
        parts.push('Extracted with PTIT Code Helper - https://anvu.web.app/pch');
        return parts.join('\n');
    };

    const submitCode = code => {
        if (!code || !code.trim()) return alert('Code trống!');
        const fi = document.querySelector("input[type='file']"), btn = document.querySelector('.submit__pad__btn');
        if (!fi || !btn) return alert('Không tìm thấy form nộp bài!');
        const dt = new DataTransfer();
        dt.items.add(new File([code], 'pch.cpp', { type: 'text/plain' }));
        fi.files = dt.files;
        fi.dispatchEvent(new Event('change', { bubbles: true }));
        setTimeout(() => btn.click(), 500);
    };

    const fb = btn => { const o = btn.innerHTML; btn.innerHTML = I.check; setTimeout(() => btn.innerHTML = o, 1500); };

    const injectStyles = () => {
        if (document.getElementById('pch-us-style')) return;
        const s = document.createElement('style');
        s.id = 'pch-us-style';
        s.textContent = CSS;
        document.head.appendChild(s);
    };

    const injectTitleButtons = () => {
        const titleEl = document.querySelector(SELECTOR_TITLE);
        if (!titleEl || titleEl.parentNode.querySelector('.pch-title-actions')) return;
        const wrap = document.createElement('span');
        wrap.className = 'pch-title-actions';
        const mk = (icon, title, fn) => { const b = document.createElement('span'); b.className = 'pch-icon-btn'; b.title = title; b.innerHTML = icon; b.onclick = fn; return b; };

        wrap.appendChild(mk(I.copy, 'Sao chép bài tập', function () { GM_setClipboard(generateProblemText()); fb(this); }));

        wrap.appendChild(mk(I.download, 'Tải bài tập (.txt)', function () {
            const blob = new Blob([generateProblemText()], { type: 'text/plain' }), a = document.createElement('a');
            a.href = URL.createObjectURL(blob); a.download = getProblemInfo().code + '.txt'; a.click(); URL.revokeObjectURL(a.href); fb(this);
        }));

        wrap.appendChild(mk(I.cph, 'Nhập vào CPH', function () {
            const self = this, orig = self.innerHTML;
            self.innerHTML = I.loading;
            const tests = getTests(), info = getProblemInfo();
            if (!tests.length) { self.innerHTML = orig; return alert('Không tìm thấy test case!'); }
            const cphName = PROBLEM_ID.length > 10 ? 'CONTEST_' + PROBLEM_ID.slice(0, 3).toUpperCase() : info.name;
            GM_xmlhttpRequest({
                method: 'POST', url: 'http://localhost:27121/',
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify({
                    group: 'PTIT Code Helper', interactive: false, memoryLimit: 128, timeLimit: 5000,
                    testType: 'single', input: { type: 'stdin' }, output: { type: 'stdout' },
                    languages: { java: { mainClass: 'Main', taskClass: cphName } },
                    name: cphName, url: info.url, tests: tests
                }),
                onload: res => { if (res.status === 200) { self.innerHTML = I.check; setTimeout(() => self.innerHTML = orig, 1500); } else { self.innerHTML = orig; alert('Không thể kết nối với CPH!'); } },
                onerror: () => { self.innerHTML = orig; alert('Không thể kết nối với CPH!'); }
            });
        }));

        const prefixes = Object.keys(GITHUB_REPOS).sort((a, b) => b.length - a.length);
        const mp = prefixes.find(p => PROBLEM_ID.startsWith(p));
        if (mp) wrap.appendChild(mk(I.github, 'Tìm bài giải mẫu (GitHub)', () => window.open('https://github.com/' + GITHUB_REPOS[mp] + '/search?q=' + PROBLEM_ID, '_blank')));
        titleEl.parentNode.appendChild(wrap);
    };

    const injectCopyButtons = () => {
        document.querySelectorAll('table:not([data-pch])').forEach(t => {
            if (!t.rows || !t.rows.length) return;
            const cols = [];
            t.rows[0].querySelectorAll('th,td').forEach(c => { if (TABLE_REGEX.test(c.innerText)) cols.push(c.cellIndex); });
            if (!cols.length) return;
            t.querySelectorAll('tr').forEach(r => cols.forEach(ci => {
                if (r.cells.length <= ci) return;
                const c = r.cells[ci];
                if (TABLE_REGEX.test(c.innerText) || c.querySelector('.pch-copy-btn')) return;
                c.style.position = 'relative';
                const b = document.createElement('span');
                b.className = 'pch-copy-btn';
                b.title = 'Copy content';
                b.innerHTML = I.copy;
                b.onclick = e => { e.stopPropagation(); e.preventDefault(); GM_setClipboard(getTextFromElement(c)); fb(b); };
                c.appendChild(b);
            }));
            t.dataset.pch = '1';
        });
    };

    let aceEditor = null;
    const getAceMode = text => {
        if (/Java/i.test(text)) return 'java';
        if (/Python/i.test(text)) return 'python';
        if (/Golang/i.test(text)) return 'golang';
        if (/C#/i.test(text)) return 'csharp';
        return 'c_cpp';
    };

    const injectEditor = () => {
        const target = document.querySelector(SELECTOR_CONTENT);
        if (!target || document.querySelector('.pch-helper-box')) return;

        const box = document.createElement('div');
        box.className = 'pch-helper-box';
        const header = document.createElement('div');
        header.className = 'pch-box-header';
        header.innerHTML = '<a href="https://anvu.web.app/pch" target="_blank" class="pch-box-title" style="text-decoration:none;color:#d0011b;font-weight:bold;display:flex;align-items:center;gap:6px">' + I.code + ' PTIT Code Helper</a>';
        const toolbar = document.createElement('div');
        toolbar.className = 'pch-toolbar';

        const langSelect = document.createElement('select');
        langSelect.className = 'pch-btn';
        const siteCompiler = document.querySelector('#compiler');
        if (siteCompiler) {
            [...siteCompiler.options].forEach(o => { const opt = document.createElement('option'); opt.value = o.value; opt.text = o.text; langSelect.add(opt); });
            langSelect.value = siteCompiler.value;
            langSelect.onchange = () => {
                siteCompiler.value = langSelect.value;
                siteCompiler.dispatchEvent(new Event('change'));
                if (aceEditor) aceEditor.session.setMode('ace/mode/' + getAceMode(langSelect.options[langSelect.selectedIndex].text));
            };
        }
        toolbar.appendChild(langSelect);

        const mkTb = (icon, text, fn) => { const b = document.createElement('button'); b.className = 'pch-btn'; b.innerHTML = icon + ' ' + text; b.onclick = fn; return b; };
        toolbar.appendChild(mkTb(I.trash, 'Xóa tất cả', () => { if (aceEditor) aceEditor.setValue(''); }));
        toolbar.appendChild(mkTb(I.paste, 'Nộp code từ bộ nhớ tạm', () => navigator.clipboard.readText().then(submitCode).catch(() => alert('Không thể đọc clipboard!'))));
        const submitBtn = document.createElement('button');
        submitBtn.className = 'pch-btn pch-btn-red';
        submitBtn.textContent = 'Nộp code trong editor';
        submitBtn.onclick = () => { if (aceEditor) submitCode(aceEditor.getValue()); };
        toolbar.appendChild(submitBtn);
        header.appendChild(toolbar);
        box.appendChild(header);

        const editorDiv = document.createElement('div');
        editorDiv.id = 'pch-ace-editor';
        box.appendChild(editorDiv);

        let insertAfter = null;
        const parent = target.parentNode;
        if (parent) {
            const w = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
            let n;
            while (n = w.nextNode()) { if (n.textContent.includes('Giới hạn')) insertAfter = n.parentElement; }
        }
        if (insertAfter && insertAfter.nextSibling) insertAfter.parentNode.insertBefore(box, insertAfter.nextSibling);
        else target.parentNode.insertBefore(box, target.nextSibling);

        const tryAce = () => {
            if (!unsafeWindow.ace) return false;
            aceEditor = unsafeWindow.ace.edit('pch-ace-editor');
            aceEditor.setTheme('ace/theme/textmate');
            aceEditor.setFontSize(14);
            aceEditor.setShowPrintMargin(false);
            if (langSelect.options.length) aceEditor.session.setMode('ace/mode/' + getAceMode(langSelect.options[langSelect.selectedIndex].text));
            return true;
        };
        if (!tryAce()) {
            const obs = new MutationObserver((_, o) => { if (tryAce()) o.disconnect(); });
            obs.observe(document.documentElement, { childList: true, subtree: true });
            setTimeout(() => { obs.disconnect(); tryAce(); }, 10000);
        }

        const overlay = document.createElement('div');
        overlay.className = 'pch-drag-overlay';
        overlay.innerHTML = '<div class="pch-drag-content">' + I.upload + '<div class="pch-drag-text">Thả để nhập vào Code Editor</div></div>';
        overlay.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); });
        overlay.addEventListener('drop', e => {
            e.preventDefault(); e.stopPropagation(); overlay.classList.remove('active');
            if (e.dataTransfer.files.length && aceEditor) { const r = new FileReader(); r.onload = ev => aceEditor.setValue(ev.target.result, -1); r.readAsText(e.dataTransfer.files[0]); }
        });
        document.body.appendChild(overlay);
        let dc = 0;
        document.addEventListener('dragenter', e => { if (e.dataTransfer.types.includes('Files')) { dc++; overlay.classList.add('active'); } });
        document.addEventListener('dragleave', () => { if (--dc === 0) overlay.classList.remove('active'); });
        document.addEventListener('drop', () => { dc = 0; overlay.classList.remove('active'); });
        document.addEventListener('paste', e => {
            if (!aceEditor) return;
            const t = document.querySelector('.ace_text-input');
            if (t && document.activeElement === t) return;
            const items = e.clipboardData?.items;
            if (!items) return;
            for (const i of items) if (i.kind === 'file') { e.preventDefault(); const f = i.getAsFile(); if (f) { const r = new FileReader(); r.onload = ev => aceEditor.setValue(ev.target.result, -1); r.readAsText(f); } break; }
        });
    };

    const init = () => {
        if (document.querySelector('.pch-helper-box')) return;
        if (!document.querySelector(SELECTOR_CONTENT)) return;
        injectStyles();
        injectTitleButtons();
        injectCopyButtons();
        injectEditor();
    };

    const waitAndInit = () => {
        if (init(), document.querySelector('.pch-helper-box')) return;
        const obs = new MutationObserver(() => { if (document.querySelector(SELECTOR_CONTENT)) { obs.disconnect(); init(); } });
        obs.observe(document.body || document.documentElement, { childList: true, subtree: true });
    };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', waitAndInit);
    else waitAndInit();
})();