AHC Visualizer → AtCoder CustomTest Runner (fixed responseText guards)

textarea#input の内容を AtCoder custom_test に送信。responseText を安全に扱う修正版。

// ==UserScript==
// @name         AHC Visualizer → AtCoder CustomTest Runner (fixed responseText guards)
// @namespace    idk
// @version      1.2
// @description  textarea#input の内容を AtCoder custom_test に送信。responseText を安全に扱う修正版。
// @match        https://img.atcoder.jp/ahc*
// @match        https://atcoder.jp/contests/*/custom_test
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        unsafeWindow
// @connect      atcoder.jp
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const ENC = encodeURIComponent;
  const TOKEN_KEY = 'ahc_csrf_token_v1';
  const CONTEST_KEY = 'ahc_contest_slug_v1';

  // save token/contest when on atcoder custom_test page
  if (location.host === 'atcoder.jp' && /\/contests\/[^/]+\/custom_test/.test(location.pathname)) {
    try {
      let token = (typeof unsafeWindow !== 'undefined' && unsafeWindow.csrfToken) ? unsafeWindow.csrfToken : null;
      if (!token) {
        const el = document.querySelector('input[name="csrf_token"]');
        if (el) token = el.value;
      }
      const contest = location.pathname.split('/')[2] || null;
      if (token) GM_setValue(TOKEN_KEY, token);
      if (contest) GM_setValue(CONTEST_KEY, contest);
      console.log('AHC Runner: saved token/contest', { token: !!token, contest });
    } catch (e) {
      console.warn('AHC Runner: failed to save token', e);
    }
    return;
  }

  GM_addStyle(`
    #ahc-mini { position: fixed; right:12px; top:12px; width:420px; max-width:86vw; z-index:2147483647;
      background:#fff; border:1px solid #ddd; padding:10px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,0.12);}
    #ahc-mini textarea{ width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; resize:vertical;}
    #ahc-mini .status{ font-size:12px; margin-top:6px; color:#333; word-break:break-word;}
  `);

  const root = document.createElement('div');
  root.id = 'ahc-mini';
  // 1. root.innerHTML は一旦言語selectの中身だけ空にしておく
  root.innerHTML = `
  <div style="font-weight:700;margin-bottom:6px">AHC Visualizer → AtCoder Runner</div>
  <div>
    <label style="font-size:12px">Language:</label>
    <select id="ahc-lang" style="margin-left:6px;">
      <option>Loading...</option>
    </select>
  </div>
  <textarea id="ahc-code" placeholder="Paste your source code here (required)" rows="8"></textarea>
  <div style="margin-top:6px;">
    <button id="ahc-run" style="font-size:110%">Run</button>
    <button id="ahc-open" style="margin-left:6px">Open Code Test</button>
  </div>
  <div class="status" id="ahc-status">Initializing...</div>
`;

  // 2. 言語リストを取得してセレクトを更新する関数
  async function updateLanguageSelect(contest) {
    const langSel = document.getElementById('ahc-lang');
    langSel.innerHTML = ''; // 一旦クリア

    const langs = await fetchAllowedLanguages(contest);
    if (!langs || Object.keys(langs).length === 0) {
      langSel.innerHTML = '<option>Failed to get languages</option>';
      return;
    }

    for (const [id, name] of Object.entries(langs)) {
      const option = document.createElement('option');
      option.value = id;
      option.textContent = name + ' — ' + id;
      langSel.appendChild(option);
    }
  }

  // 3. スクリプト初期化時に contest を推測 or 保存値から取り出し言語を読み込む
  (async () => {
    const t = await GM_getValue(TOKEN_KEY);
    let c = await GM_getValue(CONTEST_KEY) || guessContestFromImg();
    if (!c) {
      statusEl.textContent = 'Cannot guess contest. Please open custom_test page once.';
      return;
    }
    await updateLanguageSelect(c);

    if (t && c) statusEl.textContent = `Token acquired (contest=${c}). Contents of #input will be sent as stdin.`;
    else statusEl.textContent = 'Token not acquired: Please open custom_test page once (auto open possible).';
  })();

  document.body.appendChild(root);

  const statusEl = document.getElementById('ahc-status');
  const runBtn = document.getElementById('ahc-run');
  const openBtn = document.getElementById('ahc-open');
  const codeTa = document.getElementById('ahc-code');
  const langSel = document.getElementById('ahc-lang');

  function guessContestFromImg() {
    const m = location.href.match(/img\.atcoder\.jp\/([^\/?#/]+)/);
    return m ? m[1] : null;
  }

  async function ensureTokenAndContest(timeout = 30000) {
    let token = await GM_getValue(TOKEN_KEY);
    let contest = await GM_getValue(CONTEST_KEY);
    if (!contest) {
      const g = guessContestFromImg();
      if (g) { contest = g; await GM_setValue(CONTEST_KEY, contest); }
    }
    if (token && contest) return { token, contest };

    const guessed = contest || guessContestFromImg();
    if (!guessed) {
      statusEl.textContent = 'Cannot guess contest. Check URL.';
      throw new Error('contest slug not found');
    }

    statusEl.textContent = 'Token not acquired: Opening custom_test in new tab (manual ok)';
    try { GM_openInTab(`https://atcoder.jp/contests/${guessed}/custom_test`, { active:false, insert:true }); }
    catch (e) { statusEl.textContent = 'Auto open blocked. Please open manually.'; }

    const start = Date.now();
    while (Date.now() - start < timeout) {
      token = await GM_getValue(TOKEN_KEY);
      contest = await GM_getValue(CONTEST_KEY);
      if (token && contest) { statusEl.textContent = 'Token acquired. Ready to run.'; return { token, contest }; }
      await sleep(400);
    }
    statusEl.textContent = 'Token acquisition timed out. Please open custom_test page once.';
    throw new Error('token timeout');
  }

  async function fetchAllowedLanguages(contest) {
    const url = `https://atcoder.jp/contests/${contest}/custom_test`;
    return await new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload: res => {
          try {
            const respText = String(res.responseText ?? "");
            const doc = new DOMParser().parseFromString(respText, 'text/html');
            const sel = doc.querySelector('select[name="data.LanguageId"], select#language, select[name="LanguageId"]');
            if (!sel) return resolve({});
            const opts = Array.from(sel.options).map(o => ({ id: o.value, text: o.textContent.trim() }));
            const map = {};
            opts.forEach(o => { if (o.id) map[o.id] = o.text; });
            resolve(map);
          } catch (e) { resolve({}); }
        },
        onerror: () => resolve({})
      });
    });
  }

  function findOutputElem() {
    return document.getElementById('output') || document.querySelector('textarea[name="output"], textarea#output, textarea.output') ||
      document.querySelector('[contenteditable="true"], pre, code');
  }

  async function submitAndPoll({contest, token, languageId, sourceCode, inputText}) {
    const allowed = await fetchAllowedLanguages(contest);
    console.log('allowed languages:', allowed);
    if (allowed && Object.keys(allowed).length > 0 && !allowed[languageId]) {
      const firstId = Object.keys(allowed)[0];
      statusEl.textContent = `Selected language not allowed. Automatically switching to ${allowed[firstId]} (${firstId}).`;
      languageId = firstId;
      langSel.value = languageId;
    }

    const submitUrl = `https://atcoder.jp/contests/${contest}/custom_test/submit/json`;
    const resultUrl = `https://atcoder.jp/contests/${contest}/custom_test/json?reload=true`;
    const body = `data.LanguageId=${ENC(languageId)}&sourceCode=${ENC(sourceCode)}&input=${ENC(inputText)}&csrf_token=${ENC(token)}`;

    statusEl.textContent = 'Sending...';
    const postPreview = String(body ?? "").slice(0, 1200);
    console.log('[AHC Runner] POST body preview', postPreview);
    await new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'POST',
        url: submitUrl,
        headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
        data: body,
        onload: r => {
          const resp = String(r.responseText ?? "");
          console.log('[AHC Runner] POST response', r.status, resp.slice(0, 400));
          if (r.status >= 400) return reject(new Error('submit HTTP ' + r.status + ': ' + resp));
          if (resp && /言語.*許可/.test(resp)) return reject(new Error(resp));
          resolve(r);
        },
        onerror: e => reject(new Error('submit failed: ' + e))
      });
    });

    statusEl.textContent = 'Running (polling)...';
    const start = Date.now();
    const TIMEOUT = 2 * 60 * 1000;
    while (true) {
      if (Date.now() - start > TIMEOUT) throw new Error('Result retrieval timeout');
      const data = await new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url: resultUrl,
          onload: r => {
            const resp = String(r.responseText ?? "");
            try { resolve(JSON.parse(resp)); } catch (e) { resolve({}); }
          },
          onerror: e => reject(e)
        });
      });
      console.log('[AHC Runner] poll:', data);
      if (data && ('Interval' in data)) {
        await sleep(Math.max(200, Number(data.Interval) || 500));
        continue;
      }
      if (!data || !('Result' in data)) { await sleep(300); continue; }
      return data;
    }
  }

  runBtn.addEventListener('click', async () => {
    runBtn.disabled = true;
    statusEl.textContent = 'Preparing...';
    try {
      const { token, contest } = await ensureTokenAndContest(30000);
      let languageId = String(langSel.value || '4003');
      const sourceCode = codeTa.value || '';
      if (!sourceCode.trim()) { alert('Please input source code'); runBtn.disabled = false; return; }

      const inputElem = document.getElementById('input');
      if (!inputElem) { alert('textarea#input not found'); runBtn.disabled = false; return; }
      await sleep(80);
      const inputText = inputElem.value || '';
      console.log('[AHC Runner] inputText preview:', String(inputText).slice(0, 800));
      if (!inputText && !confirm('textarea#input is empty. Run with empty input?')) { runBtn.disabled = false; return; }

      const result = await submitAndPoll({ contest, token, languageId, sourceCode, inputText });

      const stdout = (typeof result.Stdout !== 'undefined') ? String(result.Stdout) : '';
      const stderr = (typeof result.Stderr !== 'undefined') ? String(result.Stderr) : '';
      const exit = result.Result && result.Result.ExitCode;
      const time = result.Result && result.Result.TimeConsumption;

      const outEl = findOutputElem();
      if (outEl) {
        if ('value' in outEl) {
          outEl.value = stdout;
          outEl.dispatchEvent(new Event('input', { bubbles: true }));
        } else {
          outEl.textContent = stdout;
        }
      } else {
        console.log('[AHC Runner] stdout:', stdout.slice(0, 800));
      }

      statusEl.textContent = `Finished: exit=${exit} time=${time}ms stderr=${stderr ? 'present' : 'none'}`;
    } catch (err) {
      console.error('[AHC Runner] error', err);
      statusEl.textContent = 'Error: ' + (err && err.message ? err.message : err);
      alert('An error occurred during execution. Check the console.');
    } finally {
      runBtn.disabled = false;
    }
  });

  openBtn.addEventListener('click', () => {
    const g = guessContestFromImg();
    if (!g) { alert('Cannot guess contest'); return; }
    window.open(`https://atcoder.jp/contests/${g}/custom_test`, '_blank');
  });

  (async () => {
    const t = await GM_getValue(TOKEN_KEY);
    const c = await GM_getValue(CONTEST_KEY) || guessContestFromImg();
    if (t && c) statusEl.textContent = `Token acquired (contest=${c}). Contents of #input will be sent as stdin.`;
    else statusEl.textContent = 'Token not acquired: Please open custom_test page once (auto open possible).';
  })();

  function addCopyButton(textarea) {
    if (!textarea) return;

    const label = document.querySelector(`label[for="${textarea.id}"]`);

    // ラベルとボタンを同じ行に配置
    const header = document.createElement('div');
    header.style.display = 'flex';
    header.style.alignItems = 'center';
    header.style.gap = '6px'; // ラベルとボタンの間隔

    if (label) {
      textarea.parentNode.insertBefore(header, label);
      header.appendChild(label);
    } else {
      textarea.parentNode.insertBefore(header, textarea);
    }

    const btn = document.createElement('button');
    btn.textContent = 'Copy';
    btn.type = 'button';
    btn.style.padding = '1px 6px 1px 6px';
    btn.style.margin = '1px';

    btn.addEventListener('click', () => {
      navigator.clipboard.writeText(textarea.value).then(() => {
        btn.textContent = 'Copied';
        setTimeout(() => { btn.textContent = 'Copy'; }, 1000);
      }).catch(() => {
        alert('failed to copy');
      });
    });

    header.appendChild(btn);

    // textarea をラベル・ボタンの下に配置
    header.insertAdjacentElement('afterend', textarea);
  }

  window.addEventListener('load', () => {
    addCopyButton(document.getElementById('input'));
    addCopyButton(document.getElementById('output'));
  });

})();