您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램
// ==UserScript== // @name 에펨코리아 메모 // @name:ko 에펨코리아 메모 // @namespace https://fmkorea.com // @author 에펨코리아 메모 // @version 2506204 // @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 = true; 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 MEMBER_CLASS_PREFIX = 'member_'; const CONSTANTS = { SORT_BY_DATE: 'date', SORT_BY_NAME: 'name', MEMBER_CLASS_PREFIX: MEMBER_CLASS_PREFIX, PROCESSED_ATTR: 'data-fmk-processed', INLINE_MEMO_CLASS: 'fmk-inline-memo', SELECTORS: { managerOverlay: '#fmk-memo-manager-overlay', importOverlay: '#fmk-memo-import-overlay', memoList: '#memoList', unprocessedPlate: `a[data-class]:not([class*="${MEMBER_CLASS_PREFIX}"])`, processedPlate: (uid) => `.${MEMBER_CLASS_PREFIX}${uid}`, allPlates: `[class*="${MEMBER_CLASS_PREFIX}"]`, unprocessedAuthor: '.author:not(:has(.member_plate))', postBody: '.rd_body', authorAnchor: `.rd_hd .btm_area a.member_plate[class*="${MEMBER_CLASS_PREFIX}"], .author a[class*="${MEMBER_CLASS_PREFIX}"]`, docTitleLink: '.bd_tl h1 a[href^="/"]:not([href="/"])', ogUrlMeta: 'meta[property="og:url"]', memberInfoTable: 'table.table.row', loginFormPasswordInput: 'input[name="password"]', profileImageCell: 'td.profile_image', baseInfoRow: 'th[colspan]', postListRows: 'tbody tr, .list_tbody .list_row, .fm_best_widget li.li', postTitleLink: '.title a, h3.title a, td.title a', postCategoryLink: '.cate a', postTime: '.time, td.time, .regdate', postViews: '.m_no, td.m_no', postVotes: '.m_no_voted, td.m_no_voted, .pc_voted_count .count', inlineMemo: `.${'fmk-inline-memo'}`, } }; function createElement(tag, properties = {}, children = []) { const el = document.createElement(tag); for (const key in properties) { if (Object.prototype.hasOwnProperty.call(properties, key)) { if (key === 'style' && typeof properties.style === 'object') { Object.assign(el.style, properties.style); } else if (key === 'dataset' && typeof properties.dataset === 'object') { Object.assign(el.dataset, properties.dataset); } else if (key in el) { try { el[key] = properties[key]; } catch (e) { el.setAttribute(key, properties[key]); } } else { el.setAttribute(key, properties[key]); } } } children.forEach(child => { if(child) el.append(child); }); return el; } 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 { 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 { 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); display: flex; justify-content: flex-end; gap: 8px; } #fmk-memo-manager-container .manager-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-manager-container .manager-footer button:hover { background-color: var(--fmk-button-hover-bg); border-color: var(--fmk-border-secondary); } .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-area { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; border-bottom: 1px solid var(--fmk-border-primary); margin-bottom: 4px; } .fmk-popup-title { font-weight: 700; margin-right: 8px; flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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.save-btn: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; justify-content: space-between; gap: 8px; color: var(--fmk-text-secondary); } .fmk-sort-group { display: flex; align-items: center; gap: 8px; } .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); } #clearAllBtn_inline { background: none; border: 1px solid #d9534f; color: #d9534f; padding: 3px 8px; border-radius: 12px; cursor: pointer; font-size: 12px; transition: all 0.2s; } #clearAllBtn_inline:hover { background-color: #d9534f; color: white; } .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 { display: flex; justify-content: space-between; align-items: center; margin-bottom: 3px; } .history-delete-btn { background: none; border: none; color: var(--fmk-text-secondary); cursor: pointer; font-size: 16px; font-weight: bold; padding: 0 5px; border-radius: 50%; line-height: 1; width: 20px; height: 20px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .history-delete-btn:hover { background-color: var(--fmk-button-hover-bg); color: #f44336; } #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); } .quick-delete-btn { background: none; border: none; color: var(--fmk-text-secondary); cursor: pointer; font-size: 16px; font-weight: bold; padding: 0 5px; border-radius: 50%; line-height: 1; width: 20px; height: 20px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: all 0.2s; margin-left: 6px; } .quick-delete-btn:hover { background-color: var(--fmk-button-hover-bg); color: #d9534f; } .fmk-activity-tracker { display: flex; align-items: center; gap: 5px; flex-shrink: 0; } .fmk-activity-tracker input[type="checkbox"] { margin: 0; width: 15px; height: 15px; vertical-align: middle; cursor: pointer; } .fmk-activity-tracker-button { font-size: 11px; font-weight: normal; padding: 3px 7px; border-radius: 4px; color: var(--fmk-text-secondary); background-color: transparent; border: 1px solid transparent; transition: all 0.25s ease-in-out; cursor: default; } .fmk-activity-tracker input[type="checkbox"]:checked + .fmk-activity-tracker-button { font-weight: bold; color: var(--fmk-button-action-text); background-color: var(--fmk-button-action-bg); border-color: var(--fmk-button-action-bg); cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .fmk-activity-tracker input[type="checkbox"]:checked + .fmk-activity-tracker-button:hover { filter: brightness(1.1); } .fmk-simple-popup { position: absolute; z-index: 1000000; background: var(--fmk-bg-primary); color: var(--fmk-text-primary); width: 500px; /* 너비를 320px에서 450px로 확장 */ max-height: 500px; /* 최대 높이도 약간 확장 */ border-radius: 8px; box-shadow: 0 5px 25px var(--fmk-shadow-color); display: flex; flex-direction: column; animation: fmk-popup-fadein .15s ease-out; border: 1px solid var(--fmk-border-primary); } /* 팝업 헤더 */ .fmk-simple-popup-header { padding: 12px 16px; border-bottom: 1px solid var(--fmk-border-primary); font-size: 14px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; } .fmk-simple-popup-close-btn { background: none; border: none; color: var(--fmk-text-secondary); cursor: pointer; font-size: 20px; font-weight: bold; padding: 0 8px; border-radius: 50%; line-height: 1; transition: all 0.2s; } .fmk-simple-popup-close-btn:hover { background-color: var(--fmk-button-hover-bg); color: #f44336; } /* 팝업 본문 (스크롤 영역) */ .fmk-simple-popup-body { padding: 0; /* 내부 리스트에서 패딩을 관리하므로 0으로 설정 */ overflow-y: auto; flex-grow: 1; } .fmk-simple-popup-body ul { list-style: none; margin: 0; padding: 0; } /* 목록의 각 항목(li) - Flexbox 컨테이너 */ .fmk-simple-popup-body li { padding: 10px 16px; /* 좌우 패딩을 헤더와 맞춤 */ border-bottom: 1px solid var(--fmk-border-primary); display: flex; align-items: center; /* 세로 상단 정렬 */ gap: 12px; /* 왼쪽과 오른쪽 영역 사이의 간격 */ } .fmk-simple-popup-body li:last-child { border-bottom: none; } /* 게시판 이름 (왼쪽 영역) */ .activity-log-board { font-size: 13px; font-weight: bold; color: var(--fmk-text-secondary); flex-shrink: 0; /* 너비가 줄어들지 않도록 고정 */ max-width: 120px; white-space: nowrap; /* 이름이 길어도 한 줄로 표시 */ overflow: hidden; /* 넘치는 부분은 숨김 */ text-overflow: ellipsis; /* 넘치는 부분은 ...으로 표시 */ } /* 글 제목 + 메타 정보 (오른쪽 영역) */ .activity-log-main { display: flex; flex-direction: column; /* 제목과 메타정보를 세로로 정렬 */ gap: 4px; /* 제목과 메타정보 사이 간격 */ flex-grow: 1; /* 남은 공간을 모두 차지 */ min-width: 0; /* Flex 아이템이 넘칠 때 내부 요소가 올바르게 줄바꿈되도록 함 */ } .activity-log-main a { font-size: 14px; color: #4ea6ff; text-decoration: none; word-break: break-all; /* 매우 긴 글 제목이 레이아웃을 깨뜨리는 것을 방지 */ } .activity-log-main a:hover { text-decoration: underline; } /* 메타 정보 (작성시간, 조회수 등) */ .activity-log-meta { font-size: 11px; color: var(--fmk-text-secondary); } /* 로딩 메시지 */ .fmk-simple-popup-loading { text-align: center; padding: 40px; color: var(--fmk-text-secondary); } `); log('CSS 스타일 주입 완료'); const cached = {}; let panelInserted = false; let currentContextPopup = null; let currentActivityPanel = null; 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), 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) { if (!u || !u.nickname) return true; return u.nickname !== newNick; } 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; m.isMarked = m.isMarked || false; m.activityLog = m.activityLog || []; return m; } const setMemo = (el, t, c) => { let sp = el.querySelector(CONSTANTS.SELECTORS.inlineMemo); 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.SELECTORS.inlineMemo)?.remove(); function closeContextPopup() { if (currentContextPopup) { document.body.removeChild(currentContextPopup); currentContextPopup = null; document.removeEventListener('mousedown', outsideContextPopup); } } const outsideContextPopup = e => { if (currentActivityPanel && currentActivityPanel.contains(e.target)) { return; } if (currentContextPopup && !currentContextPopup.contains(e.target)) { const trackerButton = currentContextPopup.querySelector('.fmk-activity-tracker-button'); if (!trackerButton || !trackerButton.contains(e.target)) { closeContextPopup(); closeActivityPanel(); } } }; function closeActivityPanel() { if (currentActivityPanel) { document.body.removeChild(currentActivityPanel); currentActivityPanel = null; } } async function showActivityLogPanel(uid, u, x, y) { log(`[활동 기록 열기 시작] 사용자: ${uid}`); closeActivityPanel(); const closeBtn = createElement('button', { className: 'fmk-simple-popup-close-btn', textContent: '×', title: '닫기', onclick: (e) => { e.stopPropagation(); closeActivityPanel(); } }); const panelHeader = createElement('div', { className: 'fmk-simple-popup-header' }, [ createElement('span', { textContent: `${u.nickname}님의 전체 활동 기록` }), closeBtn ]); const panelBody = createElement('div', { className: 'fmk-simple-popup-body' }); const panel = createElement('div', { className: 'fmk-simple-popup', style: { left: `${x}px`, top: `${y + 30}px` } }, [ panelHeader, panelBody ]); document.body.appendChild(panel); currentActivityPanel = panel; clampPopup(panel); panelBody.innerHTML = '<div class="fmk-simple-popup-loading">최신 활동을 불러오는 중... (게시판 이름 확인 중)</div>'; try { const searchMid = detectMid(); log(`[데이터 가져오기] 현재 게시판(${searchMid})에서 검색 시작...`); const postHtml = await fetch(`https://www.fmkorea.com/search.php?mid=${searchMid}&search_target=member_srl&search_keyword=${uid}`).then(r => r.text()); log('[데이터 가져오기] Fetch 완료.'); const postDoc = new DOMParser().parseFromString(postHtml, 'text/html'); const rows = [...postDoc.querySelectorAll(CONSTANTS.SELECTORS.postListRows)]; log(`[데이터 파싱] ${rows.length}개의 행 발견.`); // 변경 시작: 각 게시글의 상세 페이지를 비동기 병렬로 요청하여 정확한 게시판 이름을 가져옵니다. const postPromises = rows.map(async (el) => { const a = el.querySelector(CONSTANTS.SELECTORS.postTitleLink); if (!a) return null; const docSrlMatch = a.href.match(/\/(\d+)(?:\?|#|$)|document_srl=(\d+)/); if (!docSrlMatch) return null; const docSrl = docSrlMatch[1] || docSrlMatch[2]; if (!docSrl) return null; const postRelativeHref = `/${docSrl}`; let boardName = '알 수 없음'; // 기본값 try { // 각 게시글 페이지를 fetch const singlePostHtml = await fetch(postRelativeHref).then(res => res.text()); const singlePostDoc = new DOMParser().parseFromString(singlePostHtml, 'text/html'); // '.bd_tl' 내부의 첫 번째 a 태그에서 게시판 이름 추출 const boardNameEl = singlePostDoc.querySelector('.bd_tl h1 a[href^="/"]'); if (boardNameEl) { boardName = boardNameEl.textContent.trim(); } else { // 만약 못 찾으면 기존 방식으로 fallback const fallbackBoardNameEl = el.querySelector('td.cate a, .category a'); if(fallbackBoardNameEl) boardName = fallbackBoardNameEl.textContent.trim(); } } catch (fetchErr) { error(`[Activity Log] 게시판 이름 가져오기 실패 (게시글: ${docSrl}):`, fetchErr); // 실패 시 기존 방식으로 시도 const fallbackBoardNameEl = el.querySelector('td.cate a, .category a'); if(fallbackBoardNameEl) boardName = fallbackBoardNameEl.textContent.trim(); } const date = (el.querySelector(CONSTANTS.SELECTORS.postTime)?.textContent || '').trim(); const views = (el.querySelector(CONSTANTS.SELECTORS.postViews)?.textContent || '').trim(); const votes = (el.querySelector(CONSTANTS.SELECTORS.postVotes)?.textContent || '').trim(); return { id: docSrl, title: a.textContent.trim(), href: postRelativeHref, board: boardName, // 새로 가져온 게시판 이름 사용 date, views, votes, timestamp: new Date(date).getTime() || Date.now() }; }); // 모든 Promise가 완료될 때까지 기다린 후 null 값을 필터링 const resolvedPosts = await Promise.all(postPromises); const newPosts = resolvedPosts.filter(p => p !== null); // 변경 끝 const existingLogIds = new Set(u.activityLog.map(p => p.id)); let hasNewData = newPosts.some(p => !existingLogIds.has(p.id)); log(`[데이터 파싱] ${newPosts.length}개의 유효한 글 파싱 완료.`); const combinedLog = [...u.activityLog, ...newPosts]; const uniqueLog = Array.from(new Map(combinedLog.map(item => [item.id, item])).values()); uniqueLog.sort((a, b) => b.timestamp - a.timestamp); const finalLog = uniqueLog.slice(0, 100); log(`[데이터 처리] 최종 기록 ${finalLog.length}개 생성 완료.`); panelBody.innerHTML = ''; if (finalLog.length === 0) { panelBody.innerHTML = '<div class="fmk-simple-popup-loading">기록된 활동이 없습니다.</div>'; } else { const ul = createElement('ul'); finalLog.forEach(post => { const metaParts = []; if (post.date) metaParts.push(post.date); if (post.views) metaParts.push(`조회 ${post.views}`); if (post.votes) metaParts.push(`추천 ${post.votes}`); const metaText = metaParts.join(' · '); const boardSpan = createElement('div', { className: 'activity-log-board', textContent: `[${post.board || '기타'}]`, title: post.board || '기타' }); const titleLink = createElement('a', { href: post.href, textContent: post.title, target: '_blank' }); const metaInfo = createElement('div', { className: 'activity-log-meta', textContent: metaText }); const mainArea = createElement('div', { className: 'activity-log-main' }, [titleLink, metaInfo]); ul.appendChild(createElement('li', {}, [ boardSpan, mainArea ])); }); panelBody.appendChild(ul); } if (hasNewData || u.activityLog.length !== finalLog.length) { log('[데이터 저장] 변경 사항 감지. 스토리지 업데이트 시작...'); u.activityLog = finalLog; await GM_setValue(uid, u); log('[데이터 저장] 스토리지 업데이트 완료.'); } else { log('[데이터 저장] 변경 사항 없음. 저장을 건너뜁니다.'); } } catch (err) { error('활동 기록을 불러오는 중 오류 발생:', err); panelBody.innerHTML = '<div class="fmk-simple-popup-loading">오류가 발생했습니다.</div>'; } } async function showMemoContextPopup(uid, u, x, y, cb) { closeContextPopup(); closeActivityPanel(); log(`우클릭 메모 팝업 열기: 사용자 ID ${uid}`); const main = createElement('textarea', { rows: 2, maxLength: 20, placeholder: '메모 (20자)', value: u.text || '' }); const detail = createElement('textarea', { rows: 5, placeholder: '세부 메모', value: u.detail || '' }); const activityCheckbox = createElement('input', { type: 'checkbox', checked: u.isMarked }); const activityButton = createElement('div', { className: 'fmk-activity-tracker-button', textContent: '활동 기록' }); const activityTracker = createElement('div', { className: 'fmk-activity-tracker' }, [ activityCheckbox, activityButton, ]); const titleArea = createElement('div', { className: 'fmk-popup-title-area' }, [ createElement('div', { className: 'fmk-popup-title', textContent: `${u.nickname || 'Unknown'}[${uid}]` }), activityTracker ]); const saveBtn = createElement('button', { textContent: '저장', className: 'save-btn' }); const delBtn = createElement('button', { textContent: '삭제', style: { display: (u.text || u.isMarked) ? 'block' : 'none' } }); const cancelBtn = createElement('button', { textContent: '취소' }); const btns = createElement('div', { className: 'fmk-popup-buttons' }, [saveBtn, delBtn, cancelBtn]); const historyList = createElement('ul'); if (u.nickHistory && u.nickHistory.length > 1) { u.nickHistory.slice().reverse().forEach(entry => historyList.appendChild(createElement('li', { textContent: `• ${entry.date}: ${entry.nick}` }))); } else { historyList.appendChild(createElement('li', { textContent: '변경 이력이 없습니다.', style: { fontStyle: 'italic' } })); } const historyContainer = createElement('div', { className: 'fmk-history-container' }, [createElement('div', { className: 'fmk-history-title', textContent: '닉네임 변경 이력' }), historyList]); const colorI = createElement('input', { type: 'color', value: u.color || getThemeAwareRandomColor() }); const colorWrap = createElement('div', { className: 'fmk-color-wrap', innerHTML: '글자 색상: ' }, [colorI]); const p = createElement('div', { className: 'fmk-context-popup', style: { left: `${x}px`, top: `${y}px` } }, [ titleArea, main, detail, historyContainer, colorWrap, btns ]); document.body.appendChild(p); currentContextPopup = p; document.addEventListener('mousedown', outsideContextPopup); clampPopup(p); activityButton.onclick = (e) => { if (activityCheckbox.checked) { showActivityLogPanel(uid, u, e.pageX, e.pageY); } }; const updateDelButtonVisibility = () => { delBtn.style.display = (main.value.trim() || activityCheckbox.checked) ? 'block' : 'none'; }; activityCheckbox.onchange = updateDelButtonVisibility; main.oninput = updateDelButtonVisibility; saveBtn.onclick = async () => { const isMarked = activityCheckbox.checked; const t = main.value.trim().slice(0, 20); if (!t && !isMarked) { delBtn.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.isMarked = isMarked; u.lastUpdate = Date.now(); if (!isMarked) u.activityLog = []; cached[uid] = u; log(`메모 저장: ID ${uid}, 내용 '${t}', 추적: ${u.isMarked}`); await GM_setValue(uid, u); closeContextPopup(); closeActivityPanel(); cb?.({ text: t, color: u.color }, false); }; delBtn.onclick = async () => { log(`메모 삭제: ID ${uid}`); await GM_deleteValue(uid); delete cached[uid]; closeContextPopup(); closeActivityPanel(); cb?.(null, true); }; cancelBtn.onclick = () => { closeContextPopup(); closeActivityPanel(); }; } function showImportPopup(onSuccess) { if (document.querySelector(CONSTANTS.SELECTORS.importOverlay)) return; const importText = createElement('textarea', { id: 'importPopupText', placeholder: '백업된 JSON 데이터를 여기에 붙여넣으세요...' }); const cancelImportBtn = createElement('button', { id: 'cancelImportBtn', textContent: '취소' }); const doImportBtn = createElement('button', { id: 'doImportBtn', textContent: '가져오기' }); const popup = createElement('div', { id: 'fmk-memo-import-popup' }, [ createElement('div', { className: 'popup-header', textContent: '메모 가져오기' }), createElement('div', { className: 'popup-content' }, [importText]), createElement('div', { className: 'popup-footer' }, [cancelImportBtn, doImportBtn]) ]); const overlay = createElement('div', { id: 'fmk-memo-import-overlay' }, [popup]); document.body.appendChild(overlay); const closePopup = () => document.body.removeChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) closePopup(); }); cancelImportBtn.onclick = closePopup; doImportBtn.onclick = async () => { 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) if (!isNaN(key)) 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() { if (document.querySelector(CONSTANTS.SELECTORS.managerOverlay)) return; let currentSortBy = CONSTANTS.SORT_BY_DATE; const searchInput = createElement('input', { type: 'text', id: 'searchMemo', placeholder: '메모 검색...' }); const memoListEl = createElement('div', { className: 'items-container', id: 'memoList' }); const exportBtn = createElement('button', { id: 'exportBtn', textContent: '클립보드로 복사' }); const showImportPopupBtn = createElement('button', { id: 'showImportPopupBtn', textContent: '붙여넣기로 가져오기' }); const closeManagerBtn = createElement('button', { id: 'closeManagerBtn', textContent: '닫기' }); const clearAllBtn = createElement('button', { id: 'clearAllBtn_inline', textContent: '전체 초기화' }); const sortBtnDate = createElement('button', { textContent: '최신순', className: 'active', dataset: { sort: CONSTANTS.SORT_BY_DATE } }); const sortBtnName = createElement('button', { textContent: '이름순', dataset: { sort: CONSTANTS.SORT_BY_NAME } }); const sortButtons = [sortBtnDate, sortBtnName]; const container = createElement('div', { id: 'fmk-memo-manager-container' }, [ createElement('div', { className: 'manager-header' }, [createElement('h1', { textContent: '메모 관리' })]), createElement('div', { className: 'manager-content' }, [ searchInput, createElement('div', { className: 'fmk-sort-controls' }, [ createElement('div', { className: 'fmk-sort-group' }, [ createElement('span', { textContent: '정렬:' }), sortBtnDate, sortBtnName ]), clearAllBtn ]), memoListEl, createElement('div', { className: 'import-export-area' }, [exportBtn, showImportPopupBtn]) ]), createElement('div', { className: 'manager-footer' }, [closeManagerBtn]) ]); const overlay = createElement('div', { id: 'fmk-memo-manager-overlay' }, [container]); document.body.appendChild(overlay); const closePanel = () => { log('메모 관리자 패널 닫기'); document.body.removeChild(overlay); }; closeManagerBtn.onclick = closePanel; overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); }); showImportPopupBtn.onclick = () => showImportPopup(() => { renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy); run(); }); clearAllBtn.onclick = async () => { if (!confirm('모든 메모를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')) return; log('모든 메모 데이터 초기화 시작'); const allKeys = await GM_listValues(); for (const key of allKeys) if (!isNaN(key)) await GM_deleteValue(key); Object.keys(cached).forEach(key => delete cached[key]); renderMemoList(cached, '', currentSortBy); clearAllInlineMemos(); alert('모든 메모가 성공적으로 삭제되었습니다.'); }; exportBtn.onclick = async () => { const dataToExport = Object.fromEntries(Object.entries(cached).filter(([key]) => !isNaN(key))); const jsonStr = JSON.stringify(dataToExport, null, 2); try { await navigator.clipboard.writeText(jsonStr); 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; 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, openedHistoryUid = null) { const memoListEl = document.getElementById("memoList"); if (!memoListEl) return; const scrollPosition = memoListEl.scrollTop; memoListEl.innerHTML = ""; let filteredData = Object.entries(data).filter(([key, val]) => { if (isNaN(key) || (!val.text && !val.isMarked)) return false; const combinedText = `${key} ${val?.nickname ?? ""} ${val?.text ?? ""} ${val?.detail ?? ""}`.toLowerCase(); return !keyword || combinedText.includes(keyword); }); filteredData.sort(([, a], [, b]) => (sortBy === CONSTANTS.SORT_BY_NAME) ? (a.nickname || '').localeCompare(b.nickname || '') : (b.lastUpdate || 0) - (a.lastUpdate || 0)); if (filteredData.length === 0) { memoListEl.appendChild(createElement('div', { textContent: keyword ? "일치하는 메모가 없습니다." : "저장된 메모가 없습니다.", style: { padding: "10px", textAlign: "center", color: "var(--fmk-text-secondary)" } })); return; } for (const [key, val] of filteredData) { const itemWrapper = createElement('div', { className: 'item-wrapper', dataset: { uid: key } }); let historyHtml = '', buttonHtml = ''; const markedIndicator = val.isMarked ? '📌' : ''; if (val.history && val.history.length > 1) { const historyItems = val.history.slice().reverse().map((h, reverseIndex) => { const originalIndex = val.history.length - 1 - reverseIndex; return `<li data-history-index="${originalIndex}"><span>• ${h.date}: ${h.text}</span><button class="history-delete-btn" title="이력 삭제">×</button></li>`; }).join(''); const historyDisplayStyle = (openedHistoryUid === key) ? 'display: block;' : 'display: none;'; historyHtml = `<div class="item-history-container" style="${historyDisplayStyle}"><div class="item-history-title">메모 변경 이력</div><ul>${historyItems}</ul></div>`; buttonHtml = `<button class="history-toggle-btn">이력</button>`; } else { buttonHtml = `<button class="quick-delete-btn" title="메모 삭제">×</button>`; } itemWrapper.innerHTML = `<div class="item"><span style="${val.color ? `border-left: 3px solid ${val.color}; padding-left: 6px;` : ''}">${markedIndicator} ${val.nickname || 'Unknown'}[${key}] : ${val.text || '(추적중)'}</span>${buttonHtml}</div>${historyHtml}`; itemWrapper.querySelector('.item')?.addEventListener('click', (e) => { if (e.target.closest('button')) return; showMemoContextPopup(key, val, e.pageX, e.pageY, () => { const searchInput = document.getElementById('searchMemo'); const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE; renderMemoList(cached, searchInput.value.trim(), currentSortBy); run(); }); }); itemWrapper.querySelector('.history-toggle-btn')?.addEventListener('click', (e) => { e.stopPropagation(); const historyContainer = itemWrapper.querySelector('.item-history-container'); if (historyContainer) { historyContainer.style.display = historyContainer.style.display === 'none' ? 'block' : 'none'; } }); memoListEl.appendChild(itemWrapper); } memoListEl.scrollTop = scrollPosition; } document.addEventListener('click', async (e) => { const memoListEl = e.target.closest(CONSTANTS.SELECTORS.memoList); if (!memoListEl) return; if (e.target.classList.contains('quick-delete-btn')) { e.stopPropagation(); const itemWrapper = e.target.closest('.item-wrapper[data-uid]'); if (!itemWrapper) return; const uid = itemWrapper.dataset.uid; const userMemo = cached[uid]; const displayName = userMemo?.nickname || `ID ${uid}`; if (confirm(`'${displayName}' 님의 메모 전체를 삭제하시겠습니까? (추적 정보도 함께 삭제됩니다)`)) { await GM_deleteValue(uid); delete cached[uid]; const searchInput = document.getElementById('searchMemo'); const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE; renderMemoList(cached, searchInput.value.trim(), currentSortBy); document.querySelectorAll(CONSTANTS.SELECTORS.processedPlate(uid)).forEach(delMemo); } } else if (e.target.classList.contains('history-delete-btn')) { e.stopPropagation(); const itemWrapper = e.target.closest('.item-wrapper[data-uid]'); if (!itemWrapper) return; const uid = itemWrapper.dataset.uid; const li = e.target.closest('li[data-history-index]'); const index = parseInt(li.dataset.historyIndex, 10); const userMemo = cached[uid]; if (userMemo && userMemo.history && !isNaN(index)) { userMemo.history.splice(index, 1); await GM_setValue(uid, userMemo); const searchInput = document.getElementById('searchMemo'); const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE; const uidToKeepOpen = userMemo.history.length > 1 ? uid : null; renderMemoList(cached, searchInput.value.trim(), currentSortBy, uidToKeepOpen); } } }); GM_registerMenuCommand('메모 관리자 열기', showManagementPanel); function getPostDate(element) { const postContainer = element.closest(CONSTANTS.SELECTORS.postListRows); if (!postContainer) return null; const timeEl = postContainer.querySelector(CONSTANTS.SELECTORS.postTime); if (!timeEl || !timeEl.textContent) return null; const dateText = timeEl.textContent.trim(); if (dateText.includes(':')) { return new Date(); } const date = new Date(dateText.replace(/\./g, '-')); return isNaN(date.getTime()) ? null : date; } const profileCheckCache = {}; async 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 && updNick(u, nick)) { log(`[닉네임 변경 감지] UID: ${uid}, 저장된 닉: '${u.nickname}', 발견된 닉: '${nick}'`); // 1차 필터링: 게시물 날짜 확인 const postDate = getPostDate(a); const lastNickChangeDate = u.nickHistory?.length > 0 ? new Date(u.nickHistory.at(-1).date) : new Date(0); if (postDate && postDate < lastNickChangeDate) { log(`[1차 필터링] 과거 게시물(${postDate.toLocaleDateString()})이므로 대표 닉네임은 갱신하지 않습니다.`); // 과거 닉네임 이력에만 추가 (선택적) if (!u.nickHistory.some(h => h.nick === nick)) { log(`[이력 추가] 과거 닉네임 '${nick}'을 이력에 추가합니다.`); u.nickHistory.push({ date: postDate.toISOString().split('T')[0], nick: nick }); // 이력 정렬 및 저장 u.nickHistory.sort((a, b) => new Date(a.date) - new Date(b.date)); await GM_setValue(uid, u); } } else { // 2차 검증: 프로필 정보 조회 (캐시 확인 후) const now = Date.now(); const lastCheck = profileCheckCache[uid] || 0; if (now - lastCheck > 60000) { // 60초(1분) 캐시 log(`[2차 검증] 프로필 정보 조회를 시작합니다. UID: ${uid}`); profileCheckCache[uid] = now; // 캐시 시간 갱신 try { const pageContextMid = new URL(location.href).searchParams.get('mid') || detectMid(); const infoHtml = await fetch(`https://www.fmkorea.com/index.php?mid=${pageContextMid}&act=dispMemberInfo&member_srl=${uid}`, { credentials: 'include' }).then(r => r.text()); const infoDoc = new DOMParser().parseFromString(infoHtml, 'text/html'); const officialNickEl = infoDoc.querySelector('a.member_plate > b, a.member_plate'); const officialNick = officialNickEl ? officialNickEl.innerText.trim() : nick; // 조회 실패 시 현재 발견된 닉으로 대체 if (u.nickname !== officialNick) { log(`[최종 갱신] 공식 닉네임 '${officialNick}'으로 정보를 업데이트합니다.`); u.nickname = officialNick; const currentDate = today(); const lastHistory = u.nickHistory.at(-1); if (!lastHistory || lastHistory.nick !== officialNick) { if (u.nickHistory.findIndex(entry => entry.date === currentDate) === -1) { u.nickHistory.push({ date: currentDate, nick: officialNick }); if (u.nickHistory.length > 30) u.nickHistory.splice(0, u.nickHistory.length - 30); a.classList.add('fmk-nick-blink'); setTimeout(() => a.classList.remove('fmk-nick-blink'), 1500); } } await GM_setValue(uid, u); } else { log(`[2차 검증] 저장된 닉네임이 공식 닉네임과 동일하여 변경하지 않습니다.`); } } catch (err) { error(`[2차 검증 실패] 프로필 정보 조회 중 오류 발생:`, err); } } else { log(`[2차 검증] 캐시된 정보가 있어 조회를 건너뜁니다.`); } } } // 메모 표시 로직 (기존과 동일) u = cached[uid]; // 최신 데이터 다시 가져오기 if (u && u.text) { setMemo(a, u.text, u.color); } // 우클릭 이벤트 핸들러 (기존과 동일) a.addEventListener('contextmenu', e => { e.preventDefault(); // 메모 팝업을 열 때는 항상 최신 캐시 데이터를 사용하도록 보장 const currentUserData = cached[uid] ||= { color: getThemeAwareRandomColor(), nickname: nick, nickHistory: [{ date: today(), nick }], history: [], lastUpdate: Date.now(), isMarked: false, activityLog: [] }; currentUserData.nickname = getNick(a); // 팝업 여는 시점의 닉네임으로 한번 더 보정 showMemoContextPopup(uid, currentUserData, e.pageX, e.pageY, (up, rm) => { document.querySelectorAll(CONSTANTS.SELECTORS.processedPlate(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']; let mid = new URL(location.href).searchParams.get('mid'); if (mid && !IGNORE_FOR_ORIGIN.includes(mid)) return mid; if (window.__fm_best_config?.target_mid) return window.__fm_best_config.target_mid; if (window.document_mid && !IGNORE_FOR_ORIGIN.includes(window.document_mid)) return window.document_mid; const docLink = document.querySelector(CONSTANTS.SELECTORS.docTitleLink); 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(CONSTANTS.SELECTORS.ogUrlMeta)?.content; const mOg = og?.match(/^https?:\/\/[^/]+\/([^/?#]+)/); if (mOg) return mOg[1]; return 'stock'; }; const authorAnchor = () => document.querySelector(CONSTANTS.SELECTORS.authorAnchor); async function injectPanel() { if (panelInserted || !document.querySelector(CONSTANTS.SELECTORS.postBody)) 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; const wrap = createElement('div', { className: 'fmk-info-panel' }); const leftBox = createElement('div', { className: 'fmk-info-panel-box fmk-info-panel-left' }, [createElement('p', { className: 'fmk-info-panel-title', innerHTML: '🛈 회원 정보' })]); const rightBox = createElement('div', { className: 'fmk-info-panel-box fmk-info-panel-right' }, [createElement('p', { className: 'fmk-info-panel-title', innerHTML: '📝 최근 작성글' })]); wrap.append(leftBox, rightBox); const bodyEl = document.querySelector(CONSTANTS.SELECTORS.postBody); bodyEl.parentNode.insertBefore(wrap, bodyEl.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(CONSTANTS.SELECTORS.memberInfoTable); if (infoTbl) { if (infoTbl.querySelector(CONSTANTS.SELECTORS.loginFormPasswordInput)) { leftBox.appendChild(createElement('p', { textContent: '회원 정보를 보려면 로그인이 필요합니다.', style: { color: 'var(--fmk-text-secondary)', padding: '20px', textAlign: 'center' }})); } else { const latestNick = infoTbl.querySelector('a.member_plate > b, a.member_plate')?.innerText.trim(); if (latestNick && cached[memberId] && cached[memberId].nickname !== latestNick) { updNick(cached[memberId], latestNick); await GM_setValue(memberId, cached[memberId]); } 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(CONSTANTS.SELECTORS.profileImageCell)?.parentElement?.remove(); [...infoTbl.rows].find(tr => tr.querySelector(CONSTANTS.SELECTORS.baseInfoRow)?.innerText.trim() === '기본 정보')?.remove(); infoTbl.querySelectorAll('th,td').forEach(c => (c.style.padding = '4px 10px')); leftBox.appendChild(infoTbl); } } else { leftBox.appendChild(createElement('p', { textContent: '정보를 불러올 수 없습니다.'})); } let postHtml = /list_?tbody|tbody/i.test(postHtmlRaw) ? postHtmlRaw : 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(CONSTANTS.SELECTORS.postListRows)].filter(el => el.querySelector(CONSTANTS.SELECTORS.postTitleLink)); const ul = createElement('ul', { className: 'fmk-info-panel-content' }); if (rows.length > 0) { rows.forEach(el => { if (el.querySelector(CONSTANTS.SELECTORS.postCategoryLink)?.textContent.trim() === '공지') return; const a = el.querySelector(CONSTANTS.SELECTORS.postTitleLink); if (!a) return; const id = (a.href.match(/document_srl=(\d+)/) || [])[1]; const meta = [ (el.querySelector(CONSTANTS.SELECTORS.postTime)?.textContent || '').trim(), (el.querySelector(CONSTANTS.SELECTORS.postViews)?.textContent || '').trim() && `조회 ${el.querySelector(CONSTANTS.SELECTORS.postViews).textContent.trim()}`, (el.querySelector(CONSTANTS.SELECTORS.postVotes)?.textContent || '').trim() && `추천 ${el.querySelector(CONSTANTS.SELECTORS.postVotes).textContent.trim()}` ].filter(Boolean).join(' · '); ul.appendChild(createElement('li', { innerHTML: `<a href="${id ? `/${id}` : a.href}" target="_blank">${a.textContent.trim()}</a>${meta ? ` <span class="fmk-post-meta">· ${meta}</span>` : ''}` })); }); } else { Object.assign(ul, { textContent: '최근 작성한 글이 없습니다.', style: { listStyle: 'none', paddingLeft: '0' } }); } rightBox.appendChild(ul); } catch (e) { error('작성자 정보 패널 데이터 로딩 중 오류 발생:', e); leftBox.appendChild(createElement('p', { textContent: '정보 로딩 중 오류가 발생했습니다.'})); rightBox.appendChild(createElement('p', { textContent: '정보 로딩 중 오류가 발생했습니다.'})); } } function clearAllInlineMemos() { document.querySelectorAll(CONSTANTS.SELECTORS.inlineMemo).forEach(el => el.remove()); } function run(container = document.body) { container.querySelectorAll(CONSTANTS.SELECTORS.unprocessedPlate).forEach(a => { const id = a.getAttribute('data-class'); if (id) a.classList.add(`${MEMBER_CLASS_PREFIX}${id}`); }); container.querySelectorAll(CONSTANTS.SELECTORS.allPlates).forEach(bindPlate); container.querySelectorAll(CONSTANTS.SELECTORS.unprocessedAuthor).forEach(bindAuthor); if (!panelInserted) injectPanel(); enhanceCopyButton(container); enhanceCommentCopyButtons(container); } async function initialize() { log('데이터 초기화 시작...'); const keys = await GM_listValues(); log(`저장된 키 ${keys.length}개 발견`); let migrationCount = 0; for (const key of keys) { if (!isNaN(key)) { let memo = await GM_getValue(key); let needsUpdate = false; if (memo && typeof memo.lastUpdate === 'string') { memo.lastUpdate = new Date(memo.lastUpdate).getTime(); needsUpdate = true; } if (memo && !memo.lastUpdate) { memo.lastUpdate = new Date(0).getTime(); needsUpdate = true; } if (needsUpdate) { await GM_setValue(key, memo); migrationCount++; } cached[key] = normalizeMemo(memo); } } if (migrationCount > 0) log(`${migrationCount}개의 기존 메모 데이터 형식을 성공적으로 변환했습니다.`); 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' && mutation.attributeName === 'style' && mutation.target.style.display !== 'none') { runOnce(); break; } } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true }); log('최적화된 MutationObserver 시작. 페이지 변경 및 속성 변경을 감시합니다.'); } function enhanceCopyButton(container = document) { const copyButton = container.querySelector('.document_address .btn_pack button'); if (!copyButton || copyButton.dataset.enhancedCopy) { return; } copyButton.dataset.enhancedCopy = 'true'; const originalUrl = copyButton.dataset.clipboardText; if (!originalUrl) return; copyButton.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const titleElement = document.querySelector('.rd_hd h1 .np_18px_span'); const title = titleElement ? titleElement.textContent.trim() : '제목을 찾을 수 없음'; const textToCopy = `${title}\n${originalUrl}`; try { await navigator.clipboard.writeText(textToCopy); const originalButtonText = copyButton.textContent; copyButton.textContent = '복사 완료!'; copyButton.disabled = true; setTimeout(() => { copyButton.textContent = originalButtonText; copyButton.disabled = false; }, 1500); } catch (err) { console.error('[FMK-MEMO] 클립보드 복사 실패:', err); alert('클립보드 복사에 실패했습니다.'); } }); } function enhanceCommentCopyButtons(container = document) { const copyButtons = container.querySelectorAll('a[title="주소복사"][data-clipboard-text]'); copyButtons.forEach(button => { if (button.dataset.enhancedCommentCopy) { return; } button.dataset.enhancedCommentCopy = 'true'; const originalUrl = button.dataset.clipboardText; if (!originalUrl) return; button.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const commentItem = button.closest('li.fdb_itm'); if (!commentItem) return; const contentElement = commentItem.querySelector('.xe_content'); if (!contentElement) return; const commentText = contentElement.textContent.trim(); const textToCopy = `${commentText}\n${originalUrl}`; try { await navigator.clipboard.writeText(textToCopy); const icon = button.querySelector('i'); if (icon) { const originalColor = icon.style.color; icon.style.transition = 'color 0.2s'; icon.style.color = '#4CAF50'; setTimeout(() => { icon.style.color = originalColor; }, 1500); } } catch (err) { console.error('[FMK-MEMO] 댓글 링크 복사 실패:', err); alert('댓글 주소 복사에 실패했습니다.'); } }); }); } initialize(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址