Niconico Allegation Autofill

ローカル保存または既定値で通報フォームを自動入力。

// ==UserScript==
// @name         Niconico Allegation Autofill
// @namespace    https://greasyfork.org/users/prozent55
// @version      1.0.0
// @description  ローカル保存または既定値で通報フォームを自動入力。
// @match        https://garage.nicovideo.jp/allegation/*
// @run-at       document-idle
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(async () => {
  'use strict';

  const DEFAULT_REASON = '91';
  const DEFAULT_TYPE   = '3';
  const DEFAULT_TEXT   = [
    '無断転載と思われる動画が多数氾濫しており、正規のコンテンツが探しにくくなるなど利用体験を大きく損なっています。',
    '私は権利者ではないため権利侵害の主張は行いませんが、スパム的投稿として規約違反の可能性があると考えています。',
    '調査と対応をご検討いただければ幸いです。'
  ].join('\n');

  const KP = 'zippy_nico_garage_min2_';
  const K_REASON = KP + 'reason';
  const K_TYPE   = KP + 'ctype';
  const K_TEXT   = KP + 'comment';

  const storage = {
    async get(k, d) {
      try { if (typeof GM?.getValue === 'function') return await GM.getValue(k, d); } catch {}
      try { if (typeof window.GM_getValue === 'function') return window.GM_getValue(k, d); } catch {}
      try { const v = localStorage.getItem('__' + k); return v == null ? d : JSON.parse(v); } catch {}
      return d;
    },
    async set(k, v) {
      try { if (typeof GM?.setValue === 'function') return await GM.setValue(k, v); } catch {}
      try { if (typeof window.GM_setValue === 'function') return window.GM_setValue(k, v); } catch {}
      try { localStorage.setItem('__' + k, JSON.stringify(v)); } catch {}
    }
  };

  const qs  = (s, r = document) => r.querySelector(s);
  const qsa = (s, r = document) => Array.from(r.querySelectorAll(s));
  const fire = (el) => { if (!el) return; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); };

  function waitForForm(ms = 15000) {
    return new Promise((resolve) => {
      const pick = () => {
        const reason  = qs('select[name="reason_id"]');
        const radios  = qsa('input[type="radio"][name="content_type"]');
        const comment = qs('textarea[name="comment"]');
        return (reason && radios.length && comment) ? { reason, radios, comment } : null;
      };
      const first = pick(); if (first) return resolve(first);
      const timer = setTimeout(() => { obs.disconnect(); resolve(null); }, ms);
      const obs = new MutationObserver(() => {
        const found = pick();
        if (found) { clearTimeout(timer); obs.disconnect(); resolve(found); }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
    });
  }

  async function apply(nodes) {
    const { reason, radios, comment } = nodes;
    const localReason = await storage.get(K_REASON, '');
    const localType   = await storage.get(K_TYPE,   '');
    const localText   = await storage.get(K_TEXT,   '');

    const chosenReason = localReason || DEFAULT_REASON;
    if ([...reason.options].some(o => o.value == chosenReason)) { reason.value = chosenReason; fire(reason); }

    const chosenType = localType || DEFAULT_TYPE;
    const r = radios.find(x => x.value === String(chosenType));
    if (r) { r.checked = true; fire(r); }

    if (!comment.value) { comment.value = localText || DEFAULT_TEXT; fire(comment); }
  }

  function addStyle(css) {
    if (typeof GM_addStyle === 'function') return GM_addStyle(css);
    const s = document.createElement('style'); s.textContent = css; document.head.appendChild(s);
  }

  function toast(text) {
    const n = document.createElement('div');
    n.textContent = text;
    Object.assign(n.style, {
      position: 'fixed', right: '16px', bottom: '110px', zIndex: 1000000,
      background: '#00c853', color: '#fff', padding: '8px 10px',
      borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,.25)', opacity: '0',
      transition: 'opacity .15s ease'
    });
    document.body.appendChild(n);
    requestAnimationFrame(() => (n.style.opacity = '1'));
    setTimeout(() => { n.style.opacity = '0'; setTimeout(() => n.remove(), 180); }, 1200);
  }

  function panel(nodes) {
  const { reason, radios, comment } = nodes;

  // 二重設置防止
  if (document.getElementById('zr-min2-host')) return;

  // Shadow Host
  const host = document.createElement('div');
  host.id = 'zr-min2-host';
  Object.assign(host.style, {
    position: 'fixed',
    right: '16px',
    bottom: '16px',
    zIndex: 999999
  });
  document.body.appendChild(host);

  // Shadow Root
  const root = host.attachShadow({ mode: 'open' });

  // Shadow 内 HTML
  const wrap = document.createElement('div');
  wrap.className = 'zr-min2';
  wrap.innerHTML = `
    <div class="row"><b>自動入力(ローカル⇄既定)</b></div>
    <div class="row">
      <button id="zr-save" type="button">保存</button>
      <button id="zr-reset" type="button">既定に戻す</button>
    </div>
  `;

  // Shadow 内 CSS(コメント通報のパネルと同一見た目)
  const style = document.createElement('style');
  style.textContent = `
    :host { all: initial; } /* ホストの継承遮断 */

    .zr-min2 {
      all: initial;
      display: block;
      font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
            "Noto Sans JP", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
      color: #fff;
      background: #0b1220cc;
      backdrop-filter: blur(6px);
      padding: 10px 12px;
      border-radius: 10px;
      box-shadow: 0 8px 20px rgba(0,0,0,.35);
    }
    .row { all: initial; display: block; margin: 6px 0; font: inherit; color: inherit; }
    b { all: initial; font: inherit; font-weight: 700; color: inherit; }

    button {
      all: initial;
      font: inherit;
      color: #fff;
      background: #1f6feb;      /* メイン(保存)は青 */
      padding: 6px 10px;
      border-radius: 6px;
      cursor: pointer;
      margin-right: 6px;
      box-shadow: 0 1px 2px rgba(0,0,0,.25);
    }
    button:hover { background: #2b7af3; }
    button:active { background: #195bd0; }
    button#zr-reset { background: #d93025; } /* 既定に戻すは赤 */
  `;

  root.append(style, wrap);

  // イベント(Shadow 内の要素にバインド)
  root.getElementById('zr-save').addEventListener('click', async () => {
    await storage.set(K_REASON, reason.value || '');
    await storage.set(K_TYPE,   radios.find(r => r.checked)?.value || '');
    await storage.set(K_TEXT,   comment.value || '');
    toast('保存しました');
  });

  root.getElementById('zr-reset').addEventListener('click', async () => {
    await storage.set(K_REASON, '');
    await storage.set(K_TYPE,   '');
    await storage.set(K_TEXT,   '');
    reason.value = DEFAULT_REASON; fire(reason);
    radios.forEach(r => r.checked = (r.value === String(DEFAULT_TYPE)));
    const checked = radios.find(x => x.checked); if (checked) fire(checked);
    comment.value = DEFAULT_TEXT; fire(comment);
    toast('既定に戻しました');
  });
}

  const nodes = await waitForForm();
  if (!nodes) return;
  await apply(nodes);
  panel(nodes);
})();