您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램
当前为
// ==UserScript== // @name 에펨코리아 메모 // @name:ko 에펨코리아 메모 // @namespace https://fmkorea.com // @author 에펨코리아 메모 // @version 2506172 // @description FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램 // @description:ko FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램 // @match https://www.fmkorea.com/* // @icon https://www.fmkorea.com/favicon.ico?2 // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addStyle // @grant GM_registerMenuCommand // @run-at document-body // @license MIT // ==/UserScript== (async () => { 'use strict'; const SCRIPT_NAME = 'FMK-MEMO-TM'; const DEBUG_MODE = false; const log = (...args) => DEBUG_MODE && console.log(`[${SCRIPT_NAME}]`, ...args); const error = (...args) => console.error(`[${SCRIPT_NAME}]`, ...args); if (window.__fmkMemoAlreadyLoaded) { log('스크립트가 이미 로드되어 있어 중복 실행을 방지합니다.'); return; } window.__fmkMemoAlreadyLoaded = true; log('스크립트 실행 시작'); const CONSTANTS = { SORT_BY_DATE: 'date', SORT_BY_NAME: 'name', MEMBER_CLASS_PREFIX: 'member_', PROCESSED_ATTR: 'data-fmk-processed', INLINE_MEMO_CLASS: 'fmk-inline-memo' }; GM_addStyle(` body:not(.night_mode) { --fmk-bg-primary: #ffffff; --fmk-bg-secondary: #ffffff; --fmk-bg-tertiary: #f0f2f5; --fmk-text-primary: #1c1e21; --fmk-text-secondary: #65676b; --fmk-border-primary: #dce0e4; --fmk-border-secondary: #bec3c9; --fmk-shadow-color: rgba(0, 0, 0, 0.15); --fmk-button-bg: #f5f6f7; --fmk-button-hover-bg: #e9eaec; --fmk-button-action-bg: #007bff; --fmk-button-action-text: #ffffff; } body.night_mode { --fmk-bg-primary: #2a2a2a; --fmk-bg-secondary: #3a3a3a; --fmk-bg-tertiary: #4a4a4a; --fmk-text-primary: #e4e6eb; --fmk-text-secondary: #b0b3b8; --fmk-border-primary: #555555; --fmk-border-secondary: #666666; --fmk-shadow-color: rgba(0, 0, 0, 0.6); --fmk-button-bg: #444444; --fmk-button-hover-bg: #555555; --fmk-button-action-bg: #4b87ff; --fmk-button-action-text: #ffffff; } #fmk-memo-manager-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 999998; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); } #fmk-memo-manager-container { font-family: 'Segoe UI', Arial, sans-serif; background-color: var(--fmk-bg-primary); color: var(--fmk-text-primary); width: 350px; max-height: 90vh; border-radius: 8px; box-shadow: 0 5px 25px var(--fmk-shadow-color); display: flex; flex-direction: column; animation: fmk-manager-fadein 0.2s ease-out; border: 1px solid var(--fmk-border-primary); } @keyframes fmk-manager-fadein { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } } #fmk-memo-manager-container .manager-header { padding: 16px; border-bottom: 1px solid var(--fmk-border-primary); } #fmk-memo-manager-container h1 { font-size: 18px; margin: 0; font-weight: 600; color: var(--fmk-text-primary); } #fmk-memo-manager-container .manager-content { padding: 16px; overflow-y: auto; flex-grow: 1; } #searchMemo { width: 100%; box-sizing: border-box; margin-bottom: 12px; padding: 8px 12px; background-color: var(--fmk-bg-secondary); color: var(--fmk-text-primary); border: 1px solid var(--fmk-border-primary); border-radius: 6px; font-size: 14px; transition: all 0.2s; } #searchMemo::placeholder { color: var(--fmk-text-secondary); } #searchMemo:focus { outline: none; border-color: var(--fmk-border-secondary); } .items-container { max-height: 280px; overflow-y: auto; margin-bottom: 16px; border: 1px solid var(--fmk-border-primary); border-radius: 8px; padding: 10px; background-color: var(--fmk-bg-secondary); } .item { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; padding: 4px; border-radius: 6px; transition: background-color 0.2s; cursor: pointer; } .item:hover { background-color: var(--fmk-bg-tertiary); } .item span { flex: 1; word-break: break-word; color: var(--fmk-text-primary); } .import-export-area { display: flex; gap: 8px; } .import-export-area button, #fmk-memo-manager-container .manager-footer button { flex: 1; background-color: var(--fmk-button-bg); color: var(--fmk-text-primary); border: 1px solid var(--fmk-border-primary); border-radius: 6px; padding: 6px 12px; cursor: pointer; transition: all 0.2s; font-size: 12px; } .import-export-area button:hover, #fmk-memo-manager-container .manager-footer button:hover { background-color: var(--fmk-button-hover-bg); border-color: var(--fmk-border-secondary); } #fmk-memo-manager-container .manager-footer { padding: 12px 16px; border-top: 1px solid var(--fmk-border-primary); text-align: right; } .fmk-context-popup { position: absolute; z-index: 999999; background: var(--fmk-bg-primary); color: var(--fmk-text-primary); border: 1px solid var(--fmk-border-primary); border-radius: 8px; padding: 12px; width: 260px; font-size: 13px; box-shadow: 0 4px 12px var(--fmk-shadow-color); animation: fmk-popup-fadein 0.15s ease-out; display: flex; flex-direction: column; gap: 8px; } .fmk-popup-title { font-weight:700; margin-bottom:4px; border-bottom:1px solid var(--fmk-border-primary); padding-bottom:8px; } .fmk-context-popup textarea { width:100%; box-sizing:border-box; background:var(--fmk-bg-secondary); color:var(--fmk-text-primary); border:1px solid var(--fmk-border-primary); border-radius:6px; padding:6px; resize:vertical; } .fmk-context-popup textarea::placeholder { color: var(--fmk-text-secondary); } .fmk-history-container { margin-top: 4px; border-top: 1px solid var(--fmk-border-primary); padding-top: 8px; } .fmk-history-title { font-weight: bold; font-size: 12px; margin-bottom: 5px; color: var(--fmk-text-secondary); } .fmk-history-container ul { list-style: none; padding: 0; margin: 0; max-height: 80px; overflow-y: auto; font-size: 12px; color: var(--fmk-text-primary); } .fmk-history-container ul li { margin-bottom: 3px; } .fmk-color-wrap { margin-top: 4px; font-size: 12px; } .fmk-popup-buttons { display:flex; gap:8px; margin-top: 4px; } .fmk-popup-buttons button { flex: 1; padding: 8px 0; border-radius: 6px; font-weight: 600; font-size: 13px; cursor: pointer; background: var(--fmk-button-bg); border: 1px solid var(--fmk-border-primary); color: var(--fmk-text-primary); transition: all .15s; } .fmk-popup-buttons button:hover { background: var(--fmk-button-hover-bg); border-color: var(--fmk-border-secondary); } .fmk-popup-buttons button:first-child:hover { background: var(--fmk-button-action-bg); border-color: var(--fmk-button-action-bg); color: var(--fmk-button-action-text); } .fmk-info-panel { background: var(--fmk-bg-secondary); color: var(--fmk-text-primary); border: 1px solid var(--fmk-border-primary); margin: 32px 0; padding: 20px; border-radius: 8px; display: flex; gap: 24px; flex-wrap: nowrap; max-height: 250px; overflow: hidden; } .fmk-info-panel-box { flex: 1 1 auto; border: 1px solid var(--fmk-border-primary); background: var(--fmk-bg-primary); padding: 14px; min-width: 300px; overflow-y: auto; display: flex; flex-direction: column; border-radius: 6px; } .fmk-info-panel-left { flex-grow: 0; flex-shrink: 0; flex-basis: 320px; } .fmk-info-panel-right { overflow: hidden; } .fmk-info-panel-title { font-weight: 700; margin: 0 0 12px; color: var(--fmk-text-primary); flex-shrink: 0; } .fmk-info-panel-content { list-style: disc; padding-left: 18px; margin: 0; flex-grow: 1; overflow-y: auto; } .fmk-info-panel-content a { color: #4ea6ff; text-decoration: none; } .fmk-info-panel-content a:hover { text-decoration: underline; } .fmk-info-panel-content .fmk-post-meta { color: var(--fmk-text-secondary); font-size: 12px; } .fmk-info-panel-box table th, .fmk-info-panel-box table td { background-color: transparent !important; color: var(--fmk-text-primary) !important; } body.night_mode .fmk-info-panel { background: #1e1e1e; border: 1px solid #444; } body.night_mode .fmk-info-panel-box { background: #1e1e1e; border-color: #555; } @keyframes fmk-popup-fadein { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } .fmk-inline-memo { display: inline; font-weight: bold; padding: 1px 2px; border-radius: 4px; } .author > span { display: inline !important; } li a .fmk-inline-memo { margin-left: 0px !important; } li a .fmk-inline-memo:before, .fmk-inline-memo:before { content: "["; margin-right: 1px; } li a .fmk-inline-memo:after, .fmk-inline-memo:after { content: "]"; margin-left: 1px; } @keyframes fmk-nick-blink { 0%,100% { outline: 2px solid #ffb84d; outline-offset:2px; } 50% { outline-color: transparent; } } .fmk-nick-blink { animation: fmk-nick-blink 1.5s ease-in-out 1; } ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: var(--fmk-bg-secondary); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: var(--fmk-bg-tertiary); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--fmk-border-secondary); } input[type="color"] { vertical-align: middle; border-radius: 4px; border: 1px solid var(--fmk-border-primary); width: 35px; height: 20px; cursor: pointer; background-color: var(--fmk-bg-secondary); padding: 0; } .fmk-sort-controls { margin-bottom: 12px; font-size: 12px; display: flex; align-items: center; gap: 8px; color: var(--fmk-text-secondary); } .fmk-sort-controls button { background: none; border: 1px solid var(--fmk-border-primary); color: var(--fmk-text-secondary); padding: 3px 8px; border-radius: 12px; cursor: pointer; transition: all 0.2s; } .fmk-sort-controls button:hover { border-color: var(--fmk-border-secondary); color: var(--fmk-text-primary); } .fmk-sort-controls button.active { border-color: var(--fmk-button-action-bg); background-color: var(--fmk-button-action-bg); color: var(--fmk-button-action-text); } .item-wrapper { display: flex; flex-direction: column; border-radius: 6px; transition: background-color 0.2s; } .item-wrapper:hover { background-color: var(--fmk-bg-tertiary); } .item { cursor: pointer; } .history-toggle-btn { margin-left: 6px; background-color: transparent; color: var(--fmk-text-secondary); border: 1px solid var(--fmk-border-secondary); border-radius: 6px; padding: 2px 6px; cursor: pointer; transition: all 0.2s; font-size: 10px; flex-shrink: 0; } .history-toggle-btn:hover { background-color: var(--fmk-button-hover-bg); } .item-history-container { padding: 8px 10px 4px 10px; margin: 0 4px 4px 4px; border-top: 1px solid var(--fmk-border-primary); animation: fmk-history-fadein 0.3s ease; } @keyframes fmk-history-fadein { from { opacity: 0; } to { opacity: 1; } } .item-history-title { font-weight: bold; font-size: 11px; margin-bottom: 5px; color: var(--fmk-text-secondary); } .item-history-container ul { list-style: none; padding: 0; margin: 0; font-size: 11px; color: var(--fmk-text-secondary); } .item-history-container ul li { margin-bottom: 3px; } #fmk-memo-import-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 999999; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); } #fmk-memo-import-popup { background-color: var(--fmk-bg-primary); color: var(--fmk-text-primary); width: 320px; border-radius: 8px; box-shadow: 0 5px 25px var(--fmk-shadow-color); display: flex; flex-direction: column; animation: fmk-manager-fadein 0.2s ease-out; border: 1px solid var(--fmk-border-primary); } #fmk-memo-import-popup .popup-header { padding: 14px; border-bottom: 1px solid var(--fmk-border-primary); font-size: 16px; font-weight: 600; } #fmk-memo-import-popup .popup-content { padding: 16px; } #fmk-memo-import-popup #importPopupText { width: 100%; height: 120px; box-sizing: border-box; background: var(--fmk-bg-secondary); color: var(--fmk-text-primary); border: 1px solid var(--fmk-border-primary); border-radius: 6px; padding: 8px; resize: vertical; font-size: 13px; } #fmk-memo-import-popup .popup-footer { padding: 12px 16px; border-top: 1px solid var(--fmk-border-primary); display: flex; justify-content: flex-end; gap: 8px; } #fmk-memo-import-popup .popup-footer button { background-color: var(--fmk-button-bg); color: var(--fmk-text-primary); border: 1px solid var(--fmk-border-primary); border-radius: 6px; padding: 6px 14px; cursor: pointer; transition: all 0.2s; font-size: 13px; } #fmk-memo-import-popup .popup-footer button#doImportBtn { background-color: var(--fmk-button-action-bg); border-color: var(--fmk-button-action-bg); color: var(--fmk-button-action-text); } `); log('CSS 스타일 주입 완료'); const cached = {}; let panelInserted = false; let currentContextPopup = null; let lastRun = 0; const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; const getThemeAwareRandomColor = () => { const isDarkMode = document.body.classList.contains('night_mode'); let r, g, b; if (isDarkMode) { r = Math.floor(128 + Math.random() * 128); g = Math.floor(128 + Math.random() * 128); b = Math.floor(128 + Math.random() * 128); } else { r = Math.floor(Math.random() * 128); g = Math.floor(Math.random() * 128); b = Math.floor(Math.random() * 128); } return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).padStart(6, '0')}`; }; const getNick = el => el.innerText.trim().replace(/^\/\s*/, '') || 'Unknown'; const clampPopup = p => { const r = p.getBoundingClientRect(), pad = 8; let left = parseInt(p.style.left, 10); let top = parseInt(p.style.top, 10); if (r.right > window.innerWidth - pad) left = window.innerWidth - pad - r.width; if (r.bottom > window.innerHeight - pad) top = window.innerHeight - pad - r.height; if (left < pad) left = pad; if (top < pad) top = pad; p.style.left = left + 'px'; p.style.top = top + 'px'; }; function updNick(u, newNick) { u.nickHistory ??= []; const last = u.nickHistory.at(-1); const currentDate = today(); if (!last || last.nick !== newNick) { const todayLogIndex = u.nickHistory.findIndex(entry => entry.date === currentDate); if (todayLogIndex > -1) { return false; } log(`닉네임 변경 감지: '${last?.nick || '(없음)'}' -> '${newNick}'`); u.nickHistory.push({ date: currentDate, nick: newNick }); if (u.nickHistory.length > 30) u.nickHistory.splice(0, u.nickHistory.length - 30); u.nickname = newNick; return true; } return false; } function normalizeMemo(m) { if (!m) return null; if (!Array.isArray(m.history)) m.history = m.text ? [{ date: m.lastUpdate || today(), text: m.text }] : []; if (!Array.isArray(m.nickHistory)) { const seeds = Array.isArray(m.nicknames) ? m.nicknames : m.nickname ? [m.nickname] : []; m.nickHistory = seeds.map(n => ({ date: m.lastUpdate || today(), nick: n })); } if (!m.nickname && m.nickHistory.length) m.nickname = m.nickHistory.at(-1).nick; return m; } const setMemo = (el, t, c) => { let sp = el.querySelector(`.${CONSTANTS.INLINE_MEMO_CLASS}`); if (!sp) { sp = document.createElement('span'); sp.className = CONSTANTS.INLINE_MEMO_CLASS; el.appendChild(sp); } sp.textContent = t; Object.assign(sp.style, { color: c || '#ff7676', marginLeft: '2px', display: 'inline' }); }; const delMemo = el => el.querySelector(`.${CONSTANTS.INLINE_MEMO_CLASS}`)?.remove(); function closeContextPopup() { if (currentContextPopup) { document.body.removeChild(currentContextPopup); currentContextPopup = null; document.removeEventListener('mousedown', outsideContextPopup); } } const outsideContextPopup = e => { if (currentContextPopup && !currentContextPopup.contains(e.target)) { closeContextPopup(); } }; async function showMemoContextPopup(uid, u, x, y, cb) { closeContextPopup(); log(`우클릭 메모 팝업 열기: 사용자 ID ${uid}`); const p = document.createElement('div'); p.className = 'fmk-context-popup'; Object.assign(p.style, { left: x + 'px', top: y + 'px' }); p.innerHTML = `<div class="fmk-popup-title">${u.nickname || 'Unknown'}[${uid}]</div>`; const main = document.createElement('textarea'); main.rows = 2; main.maxLength = 20; main.placeholder = '메모 (20자)'; main.value = u.text || ''; p.appendChild(main); const detail = document.createElement('textarea'); detail.rows = 5; detail.placeholder = '세부 메모'; detail.value = u.detail || ''; p.appendChild(detail); const historyContainer = document.createElement('div'); historyContainer.className = 'fmk-history-container'; const historyTitle = document.createElement('div'); historyTitle.className = 'fmk-history-title'; historyTitle.textContent = '닉네임 변경 이력'; historyContainer.appendChild(historyTitle); const historyList = document.createElement('ul'); if (u.nickHistory && u.nickHistory.length > 1) { u.nickHistory.slice().reverse().forEach(entry => { const li = document.createElement('li'); li.textContent = `• ${entry.date}: ${entry.nick}`; historyList.appendChild(li); }); } else { const li = document.createElement('li'); li.textContent = '변경 이력이 없습니다.'; li.style.fontStyle = 'italic'; historyList.appendChild(li); } historyContainer.appendChild(historyList); p.appendChild(historyContainer); const colorWrap = document.createElement('div'); colorWrap.className = 'fmk-color-wrap'; colorWrap.innerHTML = '글자 색상: '; const colorI = document.createElement('input'); colorI.type = 'color'; colorI.value = u.color || getThemeAwareRandomColor(); colorWrap.appendChild(colorI); p.appendChild(colorWrap); const btns = document.createElement('div'); btns.className = 'fmk-popup-buttons'; const save = document.createElement('button'); save.textContent = '저장'; const del = document.createElement('button'); del.textContent = '삭제'; const cancel = document.createElement('button'); cancel.textContent = '취소'; if (!u.text) del.hidden = true; btns.append(save, del, cancel); p.appendChild(btns); document.body.appendChild(p); currentContextPopup = p; document.addEventListener('mousedown', outsideContextPopup); clampPopup(p); save.onclick = async () => { const t = main.value.trim().slice(0, 20); if (!t) { del.onclick(); return; } if (u.text !== t) { u.history ??= []; u.history.push({ date: today(), text: t }); if (u.history.length > 50) u.history.splice(0, u.history.length - 50); } u.text = t; u.detail = detail.value.trim(); u.color = colorI.value; u.lastUpdate = today(); cached[uid] = u; log(`메모 저장: ID ${uid}, 내용 '${t}'`); await GM_setValue(uid, u); closeContextPopup(); cb?.({ text: t, color: u.color }, false); }; del.onclick = async () => { log(`메모 삭제: ID ${uid}`); await GM_deleteValue(uid); delete cached[uid]; closeContextPopup(); cb?.(null, true); }; cancel.onclick = closeContextPopup; } function showImportPopup(onSuccess) { if (document.getElementById('fmk-memo-import-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'fmk-memo-import-overlay'; const popup = document.createElement('div'); popup.id = 'fmk-memo-import-popup'; popup.innerHTML = ` <div class="popup-header">메모 가져오기</div> <div class="popup-content"> <textarea id="importPopupText" placeholder="백업된 JSON 데이터를 여기에 붙여넣으세요..."></textarea> </div> <div class="popup-footer"> <button id="cancelImportBtn">취소</button> <button id="doImportBtn">가져오기</button> </div> `; overlay.appendChild(popup); document.body.appendChild(overlay); const closePopup = () => document.body.removeChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) { closePopup(); } }); popup.querySelector('#cancelImportBtn').addEventListener('click', closePopup); popup.querySelector('#doImportBtn').addEventListener('click', async () => { const importText = popup.querySelector('#importPopupText'); const jsonStr = importText.value.trim(); if (!jsonStr) { alert('가져올 데이터가 없습니다. 텍스트 영역에 JSON 데이터를 붙여넣어 주세요.'); return; } try { const newData = JSON.parse(jsonStr); if (typeof newData !== 'object' || newData === null) { throw new Error('올바른 JSON 객체 형식이 아닙니다.'); } const keys = Object.keys(newData); if (keys.length === 0) { alert('가져올 데이터가 비어있습니다.'); return; } const merge = confirm('기존 메모에 가져온 데이터를 병합(추가/덮어쓰기)하시겠습니까?\n[취소]를 누르면 모든 기존 메모를 삭제하고 새로 가져옵니다.'); if (!merge) { log('기존 데이터 삭제 후 가져오기 시작'); const allKeys = await GM_listValues(); for (const key of allKeys) { await GM_deleteValue(key); } Object.keys(cached).forEach(key => delete cached[key]); } log(`메모 가져오기: ${keys.length}개 항목 처리 시작`); let importedCount = 0; for (const key of keys) { if (isNaN(key)) continue; const normalized = normalizeMemo(newData[key]); if (normalized) { cached[key] = normalized; await GM_setValue(key, normalized); importedCount++; } } alert(`성공적으로 ${importedCount}개의 메모를 가져왔습니다.`); log('메모 가져오기 완료.'); onSuccess?.(newData); closePopup(); } catch (err) { error('데이터 가져오기 실패:', err); alert(`데이터를 가져오는 데 실패했습니다. JSON 형식이 올바른지 확인해주세요.\n\n오류: ${err.message}`); } }); } async function showManagementPanel() { log('메모 관리자 패널 열기'); if (document.getElementById('fmk-memo-manager-overlay')) return; let currentSortBy = CONSTANTS.SORT_BY_DATE; const overlay = document.createElement('div'); overlay.id = 'fmk-memo-manager-overlay'; const container = document.createElement('div'); container.id = 'fmk-memo-manager-container'; container.innerHTML = ` <div class="manager-header"> <h1>메모 관리</h1> </div> <div class="manager-content"> <input type="text" id="searchMemo" placeholder="메모 검색..." /> <div class="fmk-sort-controls"> <span>정렬:</span> <button data-sort="${CONSTANTS.SORT_BY_DATE}" class="active">최신순</button> <button data-sort="${CONSTANTS.SORT_BY_NAME}">이름순</button> </div> <div class="items-container" id="memoList"></div> <div class="import-export-area"> <button id="exportBtn">클립보드로 복사</button> <button id="showImportPopupBtn">붙여넣기로 가져오기</button> </div> </div> <div class="manager-footer"> <button id="closeManagerBtn">닫기</button> </div> `; overlay.appendChild(container); document.body.appendChild(overlay); const searchInput = container.querySelector('#searchMemo'); const sortButtons = container.querySelectorAll('.fmk-sort-controls button'); const exportBtn = container.querySelector("#exportBtn"); const closeManagerBtn = container.querySelector("#closeManagerBtn"); const showImportPopupBtn = container.querySelector("#showImportPopupBtn"); const closePanel = () => { log('메모 관리자 패널 닫기'); document.body.removeChild(overlay); }; closeManagerBtn.addEventListener('click', closePanel); overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); }); showImportPopupBtn.addEventListener("click", () => { showImportPopup(() => { renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy); run(); }); }); exportBtn.addEventListener("click", async () => { const dataToExport = {}; Object.keys(cached).forEach(key => { if (!isNaN(key)) { dataToExport[key] = cached[key]; } }); const jsonStr = JSON.stringify(dataToExport, null, 2); try { await navigator.clipboard.writeText(jsonStr); log('메모 데이터가 클립보드에 복사되었습니다.'); const originalText = exportBtn.textContent; exportBtn.textContent = '복사 완료!'; exportBtn.disabled = true; setTimeout(() => { exportBtn.textContent = originalText; exportBtn.disabled = false; }, 2000); } catch (err) { error('클립보드 복사 실패:', err); alert('클립보드 복사에 실패했습니다. 브라우저 콘솔을 확인해주세요.'); } }); searchInput.addEventListener("input", () => { renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy); }); sortButtons.forEach(button => { button.addEventListener('click', () => { currentSortBy = button.dataset.sort; log(`정렬 순서 변경: ${currentSortBy}`); sortButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy); }); }); renderMemoList(cached, '', currentSortBy); } function renderMemoList(data, keyword = "", sortBy = CONSTANTS.SORT_BY_DATE) { const memoListEl = document.getElementById("memoList"); if (!memoListEl) return; memoListEl.innerHTML = ""; const searchInput = document.getElementById('searchMemo'); let filteredData = Object.entries(data).filter(([key, val]) => { if (isNaN(key)) return false; const text = val?.text ?? ""; const detail = val?.detail ?? ""; const nickname = val?.nickname ?? ""; const combinedText = `${key} ${nickname} ${text} ${detail}`.toLowerCase(); return !keyword || combinedText.includes(keyword); }); if (sortBy === CONSTANTS.SORT_BY_NAME) { filteredData.sort(([, a], [, b]) => (a.nickname || '').localeCompare(b.nickname || '')); } else { filteredData.sort(([, a], [, b]) => new Date(b.lastUpdate || 0) - new Date(a.lastUpdate || 0)); } if (filteredData.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.textContent = keyword ? "일치하는 메모가 없습니다." : "저장된 메모가 없습니다."; emptyMsg.style.padding = "10px"; emptyMsg.style.textAlign = "center"; emptyMsg.style.color = "var(--fmk-text-secondary)"; memoListEl.appendChild(emptyMsg); return; } for (const [key, val] of filteredData) { const itemEl = document.createElement('div'); itemEl.className = 'item-wrapper'; let historyHtml = ''; if (val.history && val.history.length > 1) { const historyItems = val.history.slice().reverse().map(h => `<li>• ${h.date}: ${h.text}</li>`).join(''); historyHtml = ` <div class="item-history-container"> <div class="item-history-title">메모 변경 이력</div> <ul>${historyItems}</ul> </div>`; } itemEl.innerHTML = ` <div class="item"> <span></span> <button class="history-toggle-btn" ${!historyHtml ? 'style="display:none;"' : ''}>이력</button> </div> ${historyHtml} `; const nickSpan = itemEl.querySelector('.item span'); const displayName = `${val.nickname || 'Unknown'}[${key}]`; nickSpan.textContent = `${displayName} : ${val.text}`; if (val.color) { nickSpan.style.borderLeft = `3px solid ${val.color}`; nickSpan.style.paddingLeft = '6px'; } itemEl.querySelector('.item').addEventListener('click', (e) => { e.stopPropagation(); showMemoContextPopup(key, val, e.pageX, e.pageY, () => { log(`메모가 관리자 패널에서 수정되어 목록과 페이지를 새로고침합니다.`); const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE; renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy); run(); }); }); const historyToggleBtn = itemEl.querySelector('.history-toggle-btn'); const historyContainer = itemEl.querySelector('.item-history-container'); if (historyToggleBtn && historyContainer) { historyContainer.style.display = 'none'; historyToggleBtn.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = historyContainer.style.display !== 'none'; historyContainer.style.display = isVisible ? 'none' : 'block'; }); } memoListEl.appendChild(itemEl); } } GM_registerMenuCommand('메모 관리자 열기', showManagementPanel); log('Tampermonkey 메뉴에 "메모 관리자 열기" 등록 완료'); function bindPlate(a) { if (a.getAttribute(CONSTANTS.PROCESSED_ATTR)) return; a.setAttribute(CONSTANTS.PROCESSED_ATTR, '1'); const m = a.className.match(/member_(\d+)/); if (!m) return; const uid = m[1]; const nick = getNick(a); let u = cached[uid]; if (u) { if (updNick(u, nick)) { GM_setValue(uid, u); a.classList.add('fmk-nick-blink'); setTimeout(() => a.classList.remove('fmk-nick-blink'), 1500); } if (u.lastUpdate !== today()) { u.lastUpdate = today(); GM_setValue(uid, u); } u.text && setMemo(a, u.text, u.color); } a.addEventListener('contextmenu', e => { e.preventDefault(); u = cached[uid] ||= { color: getThemeAwareRandomColor(), nickname: nick, nickHistory: [{ date: today(), nick }], history: [], lastUpdate: today() }; showMemoContextPopup(uid, u, e.pageX, e.pageY, (up, rm) => { document.querySelectorAll(`.${CONSTANTS.MEMBER_CLASS_PREFIX}${uid}`).forEach(el => { if (rm) { delMemo(el); } else if (up?.text) { setMemo(el, up.text, up.color); } }); }); }); } const bindAuthor = el => { if (el.getAttribute(CONSTANTS.PROCESSED_ATTR)) return; el.setAttribute(CONSTANTS.PROCESSED_ATTR, '1'); const n = getNick(el); for (const [uid, u] of Object.entries(cached)) { if (u.nickname === n && u.text) { setMemo(el, u.text, u.color); break; } } }; const detectMid = () => { const IGNORE_FOR_ORIGIN = ['best', 'best2', 'best_day']; if (window.__fm_best_config?.target_mid) { return window.__fm_best_config.target_mid; } let mid = new URL(location.href).searchParams.get('mid'); if (mid && !IGNORE_FOR_ORIGIN.includes(mid)) { return mid; } if (window.document_mid && !IGNORE_FOR_ORIGIN.includes(window.document_mid)) { return window.document_mid; } const docLink = document.querySelector('.bd_tl h1 a[href^="/"]:not([href="/"])'); if (docLink) { const m = docLink.getAttribute('href').match(/^\/([^/?#]+)/); if (m && !IGNORE_FOR_ORIGIN.includes(m[1])) { return m[1]; } } const p = location.pathname.split('/').filter(Boolean); if (p.length > 0 && isNaN(p[0])) { return p[0]; } if (p.length > 1 && isNaN(p[1])) { return p[1]; } const og = document.querySelector('meta[property="og:url"]')?.content; const mOg = og?.match(/^https?:\/\/[^/]+\/([^/?#]+)/); if (mOg) { return mOg[1]; } log('모든 방법으로 mid를 찾지 못해 "stock"을 기본값으로 사용합니다.'); return 'stock'; }; const authorAnchor = () => document.querySelector('.rd_hd .btm_area a.member_plate[class*="member_"]') || document.querySelector('.author a[class*="member_"]'); async function injectPanel() { if (panelInserted) return; const body = document.querySelector('.rd_body'); if (!body) return; const anc = authorAnchor(); const m = anc?.className.match(/member_(\d+)/); if (!m) return; panelInserted = true; const memberId = m[1]; const searchMid = detectMid(); const pageContextMid = new URL(location.href).searchParams.get('mid') || searchMid; log(`패널 데이터 요청 시작: 회원정보(mid=${pageContextMid}), 최근글(mid=${searchMid}), 회원번호(${memberId})`); const wrap = document.createElement('div'); wrap.className = 'fmk-info-panel'; const leftBox = document.createElement('div'); leftBox.className = 'fmk-info-panel-box fmk-info-panel-left'; leftBox.innerHTML = `<p class="fmk-info-panel-title">🛈 회원 정보</p>`; const rightBox = document.createElement('div'); rightBox.className = 'fmk-info-panel-box fmk-info-panel-right'; rightBox.innerHTML = `<p class="fmk-info-panel-title">📝 최근 작성글</p>`; wrap.append(leftBox, rightBox); body.parentNode.insertBefore(wrap, body.nextSibling); try { const [infoHtml, postHtmlRaw] = await Promise.all([ fetch(`https://www.fmkorea.com/index.php?mid=${pageContextMid}&act=dispMemberInfo&member_srl=${memberId}`, { credentials: 'include' }).then(r => r.text()), fetch(`https://www.fmkorea.com/search.php?mid=${searchMid}&search_target=member_srl&search_keyword=${memberId}`, { credentials: 'include' }).then(r => r.text()) ]); const infoDoc = new DOMParser().parseFromString(infoHtml, 'text/html'); const infoTbl = infoDoc.querySelector('table.table.row'); if (infoTbl) { const isLoginForm = infoTbl.querySelector('input[name="password"]'); if (isLoginForm) { log('로그인되지 않은 상태 감지. 회원 정보 패널에 안내 메시지를 표시합니다.'); const loginMsg = document.createElement('p'); loginMsg.textContent = '회원 정보를 보려면 로그인이 필요합니다.'; loginMsg.style.color = 'var(--fmk-text-secondary)'; loginMsg.style.padding = '20px'; loginMsg.style.textAlign = 'center'; leftBox.appendChild(loginMsg); } else { log('회원 정보 테이블 파싱 성공'); const nickElement = infoTbl.querySelector('a.member_plate > b, a.member_plate'); if (nickElement) { const latestNick = nickElement.innerText.trim(); const u = cached[memberId]; if (u && u.nickname !== latestNick) { log(`[교정] 닉네임 강제 교정: '${u.nickname}' -> '${latestNick}'`); u.nickname = latestNick; const currentDate = today(); const todayLogIndex = u.nickHistory.findIndex(entry => entry.date === currentDate); if (todayLogIndex > -1) { u.nickHistory[todayLogIndex].nick = latestNick; } else { u.nickHistory.push({ date: currentDate, nick: latestNick }); } await GM_setValue(memberId, u); } } infoTbl.querySelectorAll('th').forEach(th => { if (th.textContent.trim().startsWith('블라인드 유저')) th.textContent = '블라인드 유저'; }); infoTbl.style.tableLayout = 'fixed'; infoTbl.querySelectorAll('th').forEach(th => (th.style.width = '90px')); infoTbl.querySelector('td.profile_image')?.parentElement?.remove(); [...infoTbl.rows].find(tr => tr.querySelector('th[colspan]')?.innerText.trim() === '기본 정보')?.remove(); infoTbl.querySelectorAll('th,td').forEach(c => (c.style.padding = '4px 10px')); leftBox.appendChild(infoTbl); } } else { log('회원 정보 테이블 파싱 실패'); leftBox.appendChild(document.createTextNode('정보를 불러올 수 없습니다.')); } let postHtml = postHtmlRaw; if (!/list_?tbody|tbody/i.test(postHtmlRaw)) { log('최근 글 목록이 없어 전역 재검색 시도'); postHtml = await fetch(`https://www.fmkorea.com/search.php?search_target=member_srl&search_keyword=${memberId}`, { credentials: 'include' }).then(r => r.text()); } const postDoc = new DOMParser().parseFromString(postHtml, 'text/html'); const rows = [...postDoc.querySelectorAll('tbody tr, .list_tbody .list_row, .fm_best_widget li.li')].filter(el => el.querySelector('.title a, h3.title a, td.title a')); log(`최근 글 파싱 완료. ${rows.length}개 항목 발견.`); const ul = document.createElement('ul'); ul.className = 'fmk-info-panel-content'; if (rows.length > 0) { rows.forEach(el => { const a = el.querySelector('.title a, h3.title a, td.title a'); if (!a) return; const id = (a.href.match(/document_srl=(\d+)/) || [])[1]; const href = id ? `/${id}` : a.href; const date = (el.querySelector('.time, td.time, .regdate')?.textContent || '').trim(); const views = (el.querySelector('.m_no, td.m_no')?.textContent || '').trim(); const votes = (el.querySelector('.m_no_voted, td.m_no_voted, .pc_voted_count .count')?.textContent || '').trim(); const meta = [date, views && `조회 ${views}`, votes && `추천 ${votes}`].filter(Boolean).join(' · '); const li = document.createElement('li'); li.innerHTML = `<a href="${href}" target="_blank">${a.textContent.trim()}</a>${meta ? ` <span class="fmk-post-meta">· ${meta}</span>` : ''}`; ul.appendChild(li); }); } else { ul.textContent = '최근 작성한 글이 없습니다.'; ul.style.listStyle = 'none'; ul.style.paddingLeft = '0'; } rightBox.appendChild(ul); } catch (e) { error('작성자 정보 패널 데이터 로딩 중 오류 발생:', e); leftBox.appendChild(document.createTextNode('정보 로딩 중 오류가 발생했습니다.')); rightBox.appendChild(document.createTextNode('정보 로딩 중 오류가 발생했습니다.')); } } function run(container = document.body) { container.querySelectorAll('a[data-class]:not([class*="member_"])').forEach(a => { const id = a.getAttribute('data-class'); if (id) { a.classList.add(`${CONSTANTS.MEMBER_CLASS_PREFIX}${id}`); } }); container.querySelectorAll(`[class*="${CONSTANTS.MEMBER_CLASS_PREFIX}"]`).forEach(bindPlate); container.querySelectorAll('.author:not(:has(.member_plate))').forEach(bindAuthor); if (!panelInserted) { injectPanel(); } } async function initialize() { log('데이터 초기화 시작...'); const keys = await GM_listValues(); log(`저장된 키 ${keys.length}개 발견`); for (const key of keys) { if (!isNaN(key)) { const memo = await GM_getValue(key); cached[key] = normalizeMemo(memo); } } log(`데이터 캐싱 완료. ${Object.keys(cached).length}개의 메모 로드.`); run(); const observer = new MutationObserver(mutations => { let processed = false; const runOnce = () => { if (processed) return; run(document.body); processed = true; setTimeout(() => { processed = false; }, 0); }; for (const mutation of mutations) { if (mutation.type === 'childList') { runOnce(); break; } if (mutation.type === 'attributes') { if (mutation.attributeName === 'style' && mutation.target.style.display !== 'none') { runOnce(); break; } } } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true }); log('최적화된 MutationObserver 시작. 페이지 변경 및 속성 변경을 감시합니다.'); } initialize(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址