// ==UserScript==
// @name Niconico Tag Allegation Autofill
// @namespace https://gf.qytechs.cn/users/prozent55
// @version 1.0.1
// @description ローカル保存または既定値でタグ通報フォームを自動入力。
// @match https://www.nicovideo.jp/comment_allegation/*
// @run-at document-idle
// @connect ext.nicovideo.jp
// @grant GM.xmlHttpRequest
// @grant GM_xmlHttpRequest
// @license MIT
// ==/UserScript==
(() => {
'use strict';
// ===== defaults =====
const DEFAULT_TARGET = 'tag';
const DEFAULT_ITEM = 'search_interference';
const DEFAULT_REASON_BODY =
'動画の内容とは無関係なタグがロックされており、削除できません。\n' +
'タグ検索や関連機能に支障をきたすため、荒らし行為にあたり利用規約違反の可能性があると判断しました。';
// ===== WL (localStorage) =====
const WL_KEY = 'zr_tag_wl_v1';
const WL_DEFAULT = [
'真夏の夜の淫夢','淫夢実況シリーズ','ひとくち淫夢','淫夢本編リンク','本編改造淫夢','BB先輩シリーズ',"ホラー淫夢","タクヤさん"
];
const loadWL = () => { try { const s = localStorage.getItem(WL_KEY); return s ? JSON.parse(s) : WL_DEFAULT.slice(); } catch { return WL_DEFAULT.slice(); } };
const saveWL = (arr) => { try { localStorage.setItem(WL_KEY, JSON.stringify(arr||[])); } catch {} };
// ===== form cache (read only) =====
const KP = 'zippy_nico_tag_form_';
const K_TARGET = KP + 'target';
const K_ITEM = KP + 'item';
const K_TEXT = KP + 'text';
const storage = {
async get(k, d){ try{ const v = localStorage.getItem('__'+k); return v==null?d:JSON.parse(v);}catch{ return d; } },
async set(k, v){ try{ localStorage.setItem('__'+k, JSON.stringify(v)); }catch{} }
};
// ===== util =====
const norm = s => (s||'').trim().toLowerCase();
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 setVal(el, val){
const proto = el?.constructor?.prototype || HTMLTextAreaElement.prototype;
const d = proto && Object.getOwnPropertyDescriptor(proto,'value');
if (d?.set) d.set.call(el, val); else el.value = val;
fire(el);
}
function toast(msg,ms=1200){ const n=document.createElement('div'); n.textContent=msg; Object.assign(n.style,{position:'fixed',right:'16px',bottom:'110px',zIndex:999999,background:'#00c853',color:'#fff',padding:'8px 10px',borderRadius:'8px',boxShadow:'0 4px 12px rgba(0,0,0,.25)',opacity:'0',transition:'opacity .15s'}); document.body.appendChild(n); requestAnimationFrame(()=>n.style.opacity='1'); setTimeout(()=>{ n.style.opacity='0'; setTimeout(()=>n.remove(),180); },ms); }
function waitForForm(ms=8000){
return new Promise(res=>{
const pick = () => {
const radios = qsa('input[type="radio"][name="target"]');
const select = qs('select[name="select_allegation"]');
const ta = qs('textarea[name="inquiry"]#inquiry');
return (radios.length && select && ta) ? {radios, select, ta} : null;
};
const first = pick(); if (first) return res(first);
const to = setTimeout(()=>{ mo.disconnect(); res(null); }, ms);
const mo = new MutationObserver(()=>{ const f = pick(); if (f){ clearTimeout(to); mo.disconnect(); res(f); }});
mo.observe(document.body,{childList:true,subtree:true});
});
}
// ===== getthumbinfo =====
function videoIdFromPath(){ const m=location.pathname.match(/\/comment_allegation\/([a-z]{2}\d+)/i); return m?m[1]:null; }
function httpGet(url){
return new Promise((resolve,reject)=>{
const fn = (typeof GM?.xmlHttpRequest==='function')?GM.xmlHttpRequest:(typeof GM_xmlhttpRequest==='function')?GM_xmlHttpRequest:null;
if (!fn) return reject(new Error('GM.xmlHttpRequest not available'));
fn({ method:'GET', url, onload:r=>resolve(r.responseText), onerror:reject });
});
}
async function fetchTags(videoId){
if (!videoId) return [];
const url = `https://ext.nicovideo.jp/api/getthumbinfo/${encodeURIComponent(videoId)}`;
let xml=''; try{ xml=await httpGet(url); }catch{ return []; }
let doc; try{ doc=new DOMParser().parseFromString(xml,'text/xml'); }catch{ return []; }
const nodes = Array.from(doc.querySelectorAll('thumb > tags > tag, tags > tag'));
return nodes.map(t=>({ name:(t.textContent||'').trim(), locked:(t.getAttribute('lock')==='1'||t.getAttribute('locked')==='1') }))
.filter(x=>x.name);
}
function filterWLAllLocked(thumbTags, wl){
const src = thumbTags.filter(t=>t.locked).map(t=>t.name);
if (!src.length || !wl.length) return [];
const S = src.map(norm); const out=[];
for (const w of wl){ const i=S.indexOf(norm(w)); if(i!==-1 && !out.includes(src[i])) out.push(src[i]); }
return out;
}
// ===== body compose =====
const TAG_LINE_RE = /^【タグの内容】.*(?:\r?\n)?/m;
function composeWithTagLine(currentText, tags){
const body0 = (currentText || '').replace(TAG_LINE_RE, '');
const tagLineOnly = `【タグの内容】\n${tags.length ? tags.join('、') : '(未特定)'}\n`;
const body = body0.trim()
? body0.replace(/^\r?\n+/, '')
: `【違反と判断された理由】\n${DEFAULT_REASON_BODY}`;
return tagLineOnly + body;
}
// ===== panel (WL edit / reset) =====
function panel(nodes, reapply){
if (document.getElementById('zr-min2-host')) return;
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);
const root = host.attachShadow({ mode:'open' });
const wrap = document.createElement('div');
wrap.className = 'zr-min2';
wrap.innerHTML = `
<div class="row"><b>自動入力(ローカル⇄既定)</b></div>
<div class="row">
<button id="zr-edit" type="button">編集</button>
<button id="zr-reset" type="button">既定に戻す</button>
</div>
`;
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);
root.getElementById('zr-edit').addEventListener('click', () => {
const cur = JSON.stringify(loadWL(), null, 2);
const nxt = prompt('ホワイトリスト(JSON配列):', cur);
if (!nxt) return;
try { saveWL(JSON.parse(nxt)); reapply({ forceDefaultBody:false }); toast('編集を保存しました'); }
catch { alert('JSONが不正です'); }
});
root.getElementById('zr-reset').addEventListener('click', () => {
saveWL(WL_DEFAULT.slice());
reapply({ forceDefaultBody:true });
toast('既定に戻しました');
});
}
// ===== main =====
(async function main(){
const nodes = await waitForForm();
if (!nodes) return;
const { radios, select, ta } = nodes;
const savedTarget = await storage.get(K_TARGET, '');
const savedItem = await storage.get(K_ITEM, '');
const savedText = await storage.get(K_TEXT, '');
const target = savedTarget || DEFAULT_TARGET;
const item = savedItem || DEFAULT_ITEM;
const r = radios.find(x => x.value === String(target)); if (r){ r.checked = true; fire(r); }
if ([...select.options].some(o => o.value === item)) { select.value = item; fire(select); }
const videoId = videoIdFromPath();
const thumbTags = await fetchTags(videoId);
const applyNow = ({ forceDefaultBody = false } = {}) => {
const wl = loadWL();
const hit = filterWLAllLocked(thumbTags, wl);
const cur = forceDefaultBody ? '' : (savedText || ta.value || '');
const next = composeWithTagLine(cur, hit);
setVal(ta, next);
const check = ta.value || '';
const ensured = check.replace(/^【タグの内容】[^\r\n]*\r?\n?/, (m) => m.endsWith('\n') ? m : (m + '\n'));
if (ensured !== check) setVal(ta, ensured);
};
applyNow();
panel(nodes, applyNow);
})();
})();