Hỗ trợ thực hành PTIT Code
// ==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();
})();