// ==UserScript==
// @name kone user note
// @namespace http://tampermonkey.net/
// @version 0.5
// @license MIT
// @description For Script User Blocking and Memo
// @author onanymous
// @match https://kone.gg/s/*
// @exclude https://kone.gg/s/*/write
// @icon https://www.google.com/s2/favicons?sz=64&domain=kone.gg
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const ARTICLE_SELECTOR = 'div.overflow-hidden .flex.items-end a.hover\\:underline span';
const DB_NAME = 'koneUserNoteDB';
const STORE_NAME = 'users';
let isHidden = true;
const USER_BLOCK_MENU_STYLE = {
position: 'absolute',
background: '#222',
color: '#fff',
padding: '10px 18px',
borderRadius: '8px',
fontSize: '14px',
zIndex: 999999,
boxShadow: '0 2px 8px rgba(0,0,0,0.16)'
};
const USER_BLOCK_USER_LABEL_STYLE = {
fontWeight: "bold",
marginBottom: "2px",
pointerEvents: "none"
};
const USER_BLOCK_NOTE_LABEL_STYLE = {
color: "#ffeb3b",
marginBottom: "6px",
fontSize: "13px",
pointerEvents: "none"
};
const USER_BLOCK_BTN_WRAP_STYLE = {
display: "flex",
flexDirection: "column",
gap: "4px"
};
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'handle' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function getBlockUsers() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(
request.result.filter(u => u.block === true).flatMap(u => u.handle)
);
request.onerror = () => reject(request.error);
});
}
async function getAllUser() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(
request.result.map(u => ({ handle: u.handle, block: u.block, username: u.username, note: u.note }))
);
request.onerror = () => reject(request.error);
});
}
async function getUser(handle) {
const db = await openDB();
handle = handle.toLowerCase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(handle);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function getNote(handle) {
const db = await openDB();
handle = handle.toLowerCase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(handle);
req.onsuccess = () => resolve(req.result && req.result.note ? req.result.note : '');
req.onerror = () => reject(req.error);
});
}
async function addBlockUser(handle, username) {
const db = await openDB();
handle = handle.toLowerCase();
username = username.toLowerCase();
let user = await getUser(handle);
let usernameArr = [];
if (user) {
const set = new Set(user.username.slice());
set.add(username);
usernameArr = Array.from(set);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({ handle, block: true, username: usernameArr, note: user.note || "" });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} else {
usernameArr = [username];
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({ handle, block: true, username: usernameArr });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
}
async function addNote(handle, username, note, overwrite = false) {
const db = await openDB();
handle = handle.toLowerCase();
username = username.toLowerCase();
let user = await getUser(handle);
let usernameArr = [];
if (user) {
const set = new Set(user.username.slice());
set.add(username);
usernameArr = Array.from(set);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({
handle,
block: user.block,
username: usernameArr,
note: overwrite ? note : (user.note ? (user.note + "\n" + note) : note)
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} else {
usernameArr = [username];
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({ handle, block: false, username: usernameArr, note: note });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
}
async function removeUser(handle) {
const db = await openDB();
handle = handle.toLowerCase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(handle);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function removeAllBlockUser() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const blocked = request.result.filter(u => u.block === true);
blocked.forEach(u => store.delete(u.handle));
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
function getHandleFromRow(el) {
const writerDiv = el.querySelector('a div.text-xs div.text-ellipsis');
return writerDiv ? writerDiv.dataset.handle : '';
}
function targetList(articles) {
const targets = [
...document.querySelectorAll('div.group\\/post-wrapper'),
...document.querySelectorAll('div.grow.flex div.flex-col div.grid > div.contents')
];
targets.forEach(row => {
const titleSpan = row.querySelector('span.overflow-hidden.text-nowrap.text-ellipsis');
if (!titleSpan) return;
const title = titleSpan.textContent.trim();
const article = articles.find(a => a.title.trim() === title);
if (!article) return;
const writerDiv = row.querySelector('.overflow-hidden.text-center.whitespace-nowrap.text-ellipsis');
writerDiv.setAttribute('data-handle', article.writer.handle);
});
}
function targetArticle(article, comments) {
const nodeList = document.querySelectorAll(ARTICLE_SELECTOR);
const [articleAuthor, ...rest] = Array.from(nodeList);
articleAuthor.setAttribute('data-handle', article.writer.handle)
comments.forEach(c => {
const commentSpan = document.querySelector(`#c_${c.id}`).parentElement.parentElement.querySelector(ARTICLE_SELECTOR);
commentSpan.setAttribute('data-handle', c.handle)
});
}
function flattenComments(arr, result = []) {
arr.forEach(item => {
result.push({
id: item.id,
content: item.content,
handle: item.writer.handle,
display_name: item.writer.display_name
});
if (Array.isArray(item.children) && item.children.length > 0) {
flattenComments(item.children, result);
}
});
return result;
}
function extractData() {
const raw = unsafeWindow.__next_f.map(x => x[1]).join('');
const start = raw.indexOf('e:[');
if (start === -1) return null;
let bracket = 0, inStr = false, esc = false, end = -1;
for (let i = start + 2; i < raw.length; i++) {
const ch = raw[i];
if (!inStr) {
if (ch === '"') inStr = true;
else if (ch === '[') bracket++;
else if (ch === ']') {
bracket--;
if (bracket === 0) {
end = i + 1;
break;
}
}
} else {
if (esc) esc = false;
else if (ch === '\\') esc = true;
else if (ch === '"') inStr = false;
}
}
if (end === -1) return null;
const arr = JSON.parse(raw.slice(start + 2, end));
return arr[3];
}
function loadData() {
const raw = extractData();
if (!raw) return;
targetList(raw.Articles);
if (raw.Article) {
targetArticle(raw.Article, flattenComments(raw.Comments));
}
}
async function hide() {
const hiddenUsers = (await getBlockUsers()).map(u => u.toLowerCase());
if (hiddenUsers.length === 0) return;
const targets = [
...document.querySelectorAll('div.group\\/post-wrapper'),
...document.querySelectorAll('div.grow.flex div.flex-col div.grid > div.contents')
];
targets.forEach(el => {
const username = getHandleFromRow(el);
if (username && hiddenUsers.includes(username.toLowerCase())) {
el.style.display = 'none';
el.dataset.toggled = true;
}
});
document.querySelectorAll(`div.group\\/comment a.hover\\:underline span`).forEach(c => {
if (c.dataset.toggled === "true") return;
if (c.dataset.handle && hiddenUsers.includes(c.dataset.handle)) {
c.dataset.hiddenUserName = c.textContent;
c.textContent = '[숨김처리 된 유저]';
c.dataset.toggled = true;
const profile = c.closest('div.group\\/comment').querySelector('img');
profile.dataset.hiddenSrc = profile.src;
profile.src = '/images/profile.png';
profile.dataset.toggled = true;
const comment = c.closest('div.group\\/comment').querySelector('p');
comment.dataset.hiddenContent = comment.textContent;
comment.textContent = '숨김처리 된 코멘트입니다.';
comment.classList.add('opacity-50');
comment.dataset.toggled = true;
}
});
}
async function show() {
const hiddenUsers = (await getBlockUsers()).map(u => u.toLowerCase());
if (hiddenUsers.length === 0) return;
const targets = [
...document.querySelectorAll('div.group\\/post-wrapper'),
...document.querySelectorAll('div.grow.flex div.flex-col div.grid > div.contents')
];
targets.forEach(el => {
if (el.dataset.toggled === "true") {
el.style.display = '';
delete el.dataset.toggled;
}
});
document.querySelectorAll('div.group\\/comment a.hover\\:underline span').forEach(c => {
if (c.dataset.toggled === "true") {
if (c.dataset.hiddenUserName) {
c.textContent = c.dataset.hiddenUserName;
delete c.dataset.hiddenUserName;
}
delete c.dataset.toggled;
const commentDiv = c.closest('div.group\\/comment');
const profile = commentDiv.querySelector('img');
if (profile && profile.dataset.toggled === "true") {
if (profile.dataset.hiddenSrc) {
profile.src = profile.dataset.hiddenSrc;
delete profile.dataset.hiddenSrc;
}
delete profile.dataset.toggled;
}
const comment = commentDiv.querySelector('p');
if (comment && comment.dataset.toggled === "true") {
if (comment.dataset.hiddenContent) {
comment.textContent = comment.dataset.hiddenContent;
delete comment.dataset.hiddenContent;
}
comment.classList.remove('opacity-50');
delete comment.dataset.toggled;
}
}
});
}
async function displayNote() {
const userDict = Object.fromEntries((await getAllUser()).map(n => [n.handle, n]));
document.querySelectorAll(ARTICLE_SELECTOR).forEach(s => {
const user = userDict[s.dataset.handle];
if (!user) return;
const anchor = s.closest('a');
if (!anchor) return;
let wrapper = anchor.nextSibling;
if (wrapper && wrapper.classList && wrapper.classList.contains('note-span-wrapper')) {
wrapper.querySelector('span').textContent = user.note;
return;
}
wrapper = document.createElement('div');
wrapper.className = 'note-span-wrapper flex';
const span = document.createElement('span');
span.className = 'text-xs';
span.textContent = user.note;
wrapper.appendChild(span);
anchor.parentNode.insertBefore(wrapper, anchor.nextSibling);
});
}
function createDiv(text, styleObj) {
const div = document.createElement('div');
div.textContent = text;
if (styleObj) Object.assign(div.style, styleObj);
return div;
}
function createBlockBtn(el, menu) {
const btn = createDiv('사용자 차단', { padding: '4px 0', cursor: 'pointer' });
btn.addEventListener('click', async () => {
const handle = el.dataset.handle || el.textContent.trim();
const username = el.textContent.trim();
await addBlockUser(handle, username);
alert(`[${el.textContent.trim()}(${el.dataset.handle})] 차단 리스트에 추가됨`);
menu.remove();
hide();
});
return btn;
}
function createNoteBtn(el, menu) {
const btn = createDiv('메모 수정/추가', { padding: '4px 0', cursor: 'pointer' });
btn.addEventListener('click', async () => {
const handle = el.dataset.handle || el.textContent.trim();
const username = el.textContent.trim();
const oldNote = await getNote(handle);
const note = prompt(
`[${username}(${handle})]에게 남길 메모를 입력:`,
oldNote || ""
);
if (note !== null) {
await addNote(handle, username, note.trim(), true);
alert('메모 저장됨');
await updateAll();
}
menu.remove();
});
return btn;
}
async function showUserBlockMenu(el) {
if (!el.dataset.handle) return;
if (window.currentUserBlockMenu) window.currentUserBlockMenu.remove();
const menu = document.createElement('div');
const rect = el.getBoundingClientRect();
Object.assign(menu.style, USER_BLOCK_MENU_STYLE, {
left: (rect.right + window.scrollX + 8) + 'px',
top: (rect.top + window.scrollY - 2) + 'px'
});
menu.className = 'username-block-menu';
const userLabel = createDiv(
`${el.textContent.trim()}(${el.dataset.handle})`,
USER_BLOCK_USER_LABEL_STYLE
);
const note = await getNote(el.dataset.handle);
let noteLabel = null;
if (note) noteLabel = createDiv(note, USER_BLOCK_NOTE_LABEL_STYLE);
const btnWrap = document.createElement('div');
Object.assign(btnWrap.style, USER_BLOCK_BTN_WRAP_STYLE);
btnWrap.appendChild(createBlockBtn(el, menu));
btnWrap.appendChild(createNoteBtn(el, menu));
menu.appendChild(userLabel);
if (noteLabel) menu.appendChild(noteLabel);
menu.appendChild(btnWrap);
menu.addEventListener('mouseleave', () => menu.remove());
document.body.appendChild(menu);
window.currentUserBlockMenu = menu;
}
function setupUsernameHoverMenu() {
let hoverTimer = null;
function bindMenuToUsernames() {
const targets = [
...document.querySelectorAll('div.group\\/post-wrapper a div.text-xs div.text-ellipsis'),
...document.querySelectorAll('div.grow.flex div.flex-col div.grid > div.contents a div.text-xs div.text-ellipsis'),
...document.querySelectorAll(ARTICLE_SELECTOR)
];
targets.forEach(el => {
if (el.dataset.menuBound) return;
el.dataset.menuBound = "1";
el.addEventListener('mouseenter', function handler(e) {
hoverTimer = setTimeout(() => {
showUserBlockMenu(el);
}, 100);
});
el.addEventListener('mouseleave', () => {
clearTimeout(hoverTimer);
setTimeout(() => {
if (window.currentUserBlockMenu) window.currentUserBlockMenu.remove();
}, 3000);
});
});
}
const mo = new MutationObserver(bindMenuToUsernames);
mo.observe(document.body, { childList: true, subtree: true });
bindMenuToUsernames();
}
function showButton() {
const targets = [
...document.querySelectorAll('div.group\\/post-wrapper[style*="display: none"]'),
...document.querySelectorAll('div.grow.flex div.flex-col div.grid > div.contents[style*="display: none"]')
];
const hidden = targets.length;
const table = document.querySelector('div.h-full.flex');
if (!table) return;
let oldRow = table.querySelector('.hidden-post-counter');
if (oldRow) oldRow.remove();
const newRow = document.createElement('div');
newRow.classList.add('hidden-post-counter', 'ml-auto');
const innerSpan1 = document.createElement('span');
innerSpan1.textContent = '숨김처리 된 게시물 : ' + hidden;
innerSpan1.classList.add('text-[13px]', 'text-center', 'pt-0.5', 'text-nowrap');
newRow.appendChild(innerSpan1);
newRow.style.cursor = 'pointer';
newRow.addEventListener('click', async () => {
isHidden = !isHidden;
await updateAll();
});
table.insertBefore(newRow, table.firstChild);
}
// ====== Tampermonkey 메뉴 등록 ======
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('차단 유저 추가', async () => {
const handle = prompt('차단할 유저 handle 입력');
if (!handle) return;
const users = (await getBlockUsers()).map(u => u.toLowerCase());
if (!users.includes(handle.toLowerCase())) {
await addBlockUser(handle, '');
alert(`[${handle}] 차단 리스트에 추가됨`);
hide();
} else {
alert('이미 추가된 유저임');
}
});
GM_registerMenuCommand('차단 유저 삭제', async () => {
const users = (await getBlockUsers()).map(u => u.toLowerCase());
if (users.length === 0) {
alert('차단 유저 없음');
return;
}
const toRemove = prompt('삭제할 유저 handle 입력 (현재 차단 유저: ' + users.join(', ') + ')');
if (!toRemove) return;
if (users.includes(toRemove.toLowerCase())) {
await removeUser(toRemove);
alert(`[${toRemove}] 차단 해제됨`);
hide();
} else {
alert(`[${toRemove}]은 차단 리스트에 없음`);
}
});
GM_registerMenuCommand('차단 유저 모두 삭제', async () => {
await removeAllBlockUser();
alert('차단 유저 모두 삭제됨');
});
GM_registerMenuCommand('차단 유저 목록 보기', async () => {
const users = await getAllUser();
const list = users.filter(u => u.block === true).map(u => `${u.username.join(', ')} (${u.handle})`).join('\n');
alert(users.length > 0 ? list : '차단 유저 없음');
});
GM_registerMenuCommand('메모 전체 목록 보기', async () => {
const notes = (await getAllUser()).filter(u => u.note && u.note.trim() !== '');
if (notes.length === 0) {
alert('작성된 메모 없음');
return;
}
alert(notes.map(n => `${n.note} - ${n.username}(${n.handle})`).join('\n'));
});
}
setupUsernameHoverMenu();
(function removeRouter() {
history.pushState = function () { };
history.replaceState = function () { };
document.addEventListener('click', function (e) {
const a = e.target.closest('a');
if (a && a.href && a.target !== '_blank') {
e.preventDefault();
window.location.href = a.href;
}
}, true);
})();
(async () => {
async function updateAll() {
loadData();
if (isHidden) await hide();
else await show();
await displayNote();
showButton();
}
window.updateAll = updateAll;
await updateAll();
const observer = new MutationObserver(async (mutations) => {
const onlyMenuChange = mutations.every(mutation =>
Array.from(mutation.addedNodes).concat(Array.from(mutation.removedNodes))
.every(node =>
node.nodeType === 1 &&
node.classList &&
node.classList.contains('username-block-menu')
)
);
if (onlyMenuChange) return;
observer.disconnect();
await updateAll();
observer.observe(document.body, { childList: true, subtree: true });
});
observer.observe(document.body, { childList: true, subtree: true });
})();
})();