ANUBIS

디시인사이드 고급 검색 및 필터링 검색 기능

// ==UserScript==
// @name         ANUBIS
// @namespace    ANUBIS-DEADlock
// @version      1.1.0
// @description  디시인사이드 고급 검색 및 필터링 검색 기능
// @author       DEADlock
// @match        https://gall.dcinside.com/*/board/lists*
// @match        https://gall.dcinside.com/board/lists*
// @match        https://gall.dcinside.com/*/board/view*
// @match        https://gall.dcinside.com/board/view*
// @icon         https://i.imgur.com/B2wWa8r.png
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // --- CONFIGURATION ---
    const CONFIG = {
        IDS: {
            STYLES: 'anubis-global-styles',
            SEARCH_MODAL: 'anubis-search-chamber',
            DIRECT_SEARCH_MODAL: 'anubis-direct-search-chamber',
            FILTER_MODAL: 'anubis-filter-chamber',
            RESULTS_MODAL: 'anubis-results-chamber',
            SETTINGS_MODAL: 'anubis-settings-chamber',
            ADVANCED_SEARCH_BTN: 'anubis-advanced-search-btn',
            SEARCH_INFO_TEXT: 'anubis-search-info-text',
            TOOLTIP: 'anubis-tooltip',
            TOP_BTN_WRAPPER: 'anubis-top-btn-wrapper',
            QUICK_SEARCH_BTN: 'anubis-quick-search-btn',
            QUICK_FILTER_BTN: 'anubis-quick-filter-btn',
        },
        CLASSES: {
            MODAL: 'anubis-chamber',
            DARK_THEME: 'anubis-dark-theme',
            TOP_ICON_BTN: 'anubis-top-icon-btn',
        },
        SELECTORS: {
            USER_POPUP_LIST: '.user_data_list',
            POST_CONTAINER: '.ub-content',
            WRITER_INFO: '.gall_writer.ub-writer',
            RECENT_GALLERY_LIST: '.newvisit_box ul.newvisit_list',
            RECENT_GALLERY_LINK: 'li a.lately_log',
            CURRENT_GALLERY_NAME: '.page_head h2 a',
            POST_ROW: 'tr.ub-content.us-post',
            POST_TITLE: 'td.gall_tit.ub-word > a:first-child',
            POST_COMMENT_COUNT: 'a.reply_numbox',
            POST_DATE: 'td.gall_date',
            POST_VIEWS: 'td.gall_count',
            POST_RECOMMEND: 'td.gall_recommend',
            TOP_AREA_RIGHT: '.list_array_option .right_box',
        },
        CONSTANTS: {
            SEARCH_CHUNK_SIZE: 5,
            MAX_SEARCH_PAGES: 200,
            MAX_DATE_SEARCH_PAGES: 1000, // Safeguard limit for date searches
            MAX_DATE_SEARCH_DAYS: 60,
        },
        STORAGE_KEYS: {
            DEFAULT_SEARCH_TYPE: 'anubis_default_search_type',
            DEFAULT_END_PAGE: 'anubis_default_end_page',
            DEFAULT_DATE_RANGE: 'anubis_default_date_range',
            FILTER_RECO_ENABLED: 'anubis_filter_reco_enabled',
            FILTER_COMMENTS_ENABLED: 'anubis_filter_comments_enabled',
            FILTER_VIEWS_ENABLED: 'anubis_filter_views_enabled',
            FILTER_MIN_RECO: 'anubis_filter_min_reco',
            FILTER_MIN_COMMENTS: 'anubis_filter_min_comments',
            FILTER_MIN_VIEWS: 'anubis_filter_min_views',
            FILTER_EXCLUDE_GALLCHU: 'anubis_filter_exclude_gallchu',
        },
        STYLES: `
            :root{--font-main:'Malgun Gothic',sans-serif;--font-size-base:13px;--font-size-header:16px;--font-size-progress:12px;--color-bg:#fff;--color-border:#ddd;--color-border-light:#e0e0e0;--color-border-dark:#ccc;--color-text-primary:#333;--color-text-secondary:#777;--color-text-link:inherit;--color-text-inverse:#fff;--color-accent:#3b4890;--color-btn-confirm-bg:#333;--color-btn-confirm-text:#fff;--color-btn-cancel-bg:#fff;--color-btn-cancel-text:#333;--color-progress-bg:#e9ecef;--color-progress-bar:#3b4890;--color-input-bg:#fff;--color-input-text:#555;--color-toggle-bg:#ccc;--color-row-hover:#f5f5f5;--svg-arrow-fill:'%23333333'}
            body.anubis-dark-theme{--color-bg:#1f1f1f;--color-border:#4a4b4f;--color-border-light:#444549;--color-border-dark:#555;--color-text-primary:#ccc;--color-text-secondary:#aaa;--color-accent:#5865f2;--color-btn-confirm-bg:#eee;--color-btn-confirm-text:#333;--color-btn-cancel-bg:#444;--color-btn-cancel-text:#e8e8e8;--color-progress-bg:#495057;--color-progress-bar:#5865f2;--color-input-bg:#1f1f1f;--color-input-text:#ccc;--color-toggle-bg:#555;--color-row-hover:#2a2b2d;--svg-arrow-fill:'%23cccccc'}
            .anubis-chamber{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:10001;display:none;font-family:var(--font-main);background-color:var(--color-bg);border:1px solid var(--color-border);color:var(--color-text-primary);border-radius:0;box-shadow:0 5px 25px rgba(0,0,0,.2);transition:background-color .3s,color .3s,border-color .3s,width .3s ease}
            .anubis-chamber-header{display:flex;justify-content:space-between;align-items:center;padding:15px 20px;border-bottom:1px solid var(--color-border-light);font-size:var(--font-size-header);font-weight:700}
            .anubis-chamber-close{background:0 0;border:none;font-size:24px;font-weight:700;color:var(--color-text-secondary);cursor:pointer;transition:color .2s}
            .anubis-chamber-close:hover:not(:disabled){color:var(--color-text-primary)}
            .anubis-chamber-close:disabled{opacity:.6;cursor:not-allowed}
            .anubis-chamber-body{padding:20px}
            .anubis-chamber-footer{display:flex;justify-content:space-between;align-items:center;padding:15px 20px}
            #anubis-settings-chamber{width:400px;max-width:95%}
            #anubis-search-chamber,#anubis-direct-search-chamber,#anubis-filter-chamber{width:450px}
            #anubis-results-chamber{width:800px;max-width:95%}
            #anubis-results-chamber.loading-state{width:600px}
            .anubis-form-group{margin-bottom:20px}
            .anubis-form-label,.anubis-settings-label{display:block;font-weight:700;font-size:var(--font-size-base);color:var(--color-text-primary);margin-bottom:8px}
            .anubis-form-description{font-size:var(--font-size-base);color:var(--color-text-secondary);margin-top:4px;margin-bottom:10px}
            #anubis-search-chamber .anubis-form-group .anubis-form-description{margin-bottom:15px}
            .anubis-input-group,.anubis-radio-group{display:flex;align-items:center;gap:10px}
            .anubis-radio-group{flex-wrap:wrap}
            .anubis-radio-group label{cursor:pointer;font-size:var(--font-size-base)}
            .anubis-text-input,.anubis-page-input,.anubis-date-input,.anubis-select-input{padding:8px;border:1px solid var(--color-border-dark);border-radius:0;font-size:var(--font-size-base);background-color:var(--color-input-bg);color:var(--color-input-text);box-sizing:border-box;transition:border-color .2s,background-color .3s,color .3s}
            .anubis-text-input:focus,.anubis-page-input:focus,.anubis-date-input:focus,.anubis-select-input:focus{outline:0;border-color:var(--color-text-primary)}
            .anubis-text-input{width:100%}
            .anubis-page-input{width:100px;text-align:center;-moz-appearance:textfield}
            .anubis-page-input::-webkit-outer-spin-button,.anubis-page-input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
            .anubis-date-input{width:150px}
            body.anubis-dark-theme .anubis-date-input{color-scheme:dark}
            .anubis-select-input{width:100%;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-image:url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22' || var(--svg-arrow-fill) || '%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.4-12.8z%22%2F%3E%3C%2Fsvg%3E');background-repeat:no-repeat;background-position:right 10px center;background-size:10px;padding-right:30px}
            body.anubis-dark-theme .anubis-select-input option{background:var(--color-input-bg);color:var(--color-input-text)}
            .anubis-slider-container{display:flex;align-items:center;gap:15px}
            .anubis-slider{flex-grow:1;-webkit-appearance:none;width:100%;height:8px;background:linear-gradient(to right,var(--color-accent) 0%,var(--color-accent) var(--slider-progress,0%),var(--color-border-light) var(--slider-progress,0%),var(--color-border-light) 100%);outline:none;border-radius:4px}
            .anubis-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;background:var(--color-accent);cursor:pointer;border-radius:50%}
            .anobis-slider::-moz-range-thumb{width:18px;height:18px;background:var(--color-accent);cursor:pointer;border-radius:50%;border:none}
            .anubis-slider-value{min-width:40px;text-align:center}
            .anubis-button-group{display:flex;gap:10px}
            .anubis-btn{padding:8px 25px;border:1px solid var(--color-border-dark);border-radius:4px;cursor:pointer;font-weight:700;font-size:var(--font-size-base);text-align:center;flex-shrink:0;transition:filter .2s,opacity .2s}
            .anubis-btn:hover:not(:disabled){filter:brightness(.9)}
            .anubis-btn:disabled{opacity:.6;cursor:not-allowed}
            .anubis-confirm-btn{background-color:var(--color-btn-confirm-bg);color:var(--color-btn-confirm-text);border-color:var(--color-btn-confirm-bg)}
            .anubis-confirm-btn:hover:not(:disabled){filter:brightness(1.4)}
            body.anubis-dark-theme .anubis-confirm-btn:hover:not(:disabled){filter:brightness(.85)}
            .anubis-cancel-btn{background-color:var(--color-btn-cancel-bg);color:var(--color-btn-cancel-text);border-color:var(--color-border-dark)}
            body.anubis-dark-theme .anubis-cancel-btn:hover:not(:disabled){filter:brightness(.8)}
            .anubis-progress-container{margin-top:5px}
            .anubis-progress-wrapper{position:relative;height:18px;background-color:var(--color-progress-bg);border-radius:4px;overflow:hidden}
            .anubis-progress-bar{width:0;height:100%;background-color:var(--color-progress-bar);transition:width .3s ease}
            .anubis-progress-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:var(--color-text-inverse);font-size:var(--font-size-progress);font-weight:700;text-shadow:1px 1px 2px rgba(0,0,0,.5)}
            body.anubis-dark-theme .anubis-progress-text{color:#fff}
            #anubis-results-chamber .anubis-chamber-body{padding:0}
            .anubis-status-container{padding:20px}
            .anubis-status-container.finished{padding:15px 20px 10px}
            .anubis-search-info-line,.anubis-date-filter-line,.anubis-status-text,.anubis-user-info{font-size:var(--font-size-base);color:var(--color-text-primary)}
            .anubis-results-list{list-style:none;padding:0 20px 10px;margin:0;max-height:60vh;overflow-y:auto}
            .anubis-results-header,.anubis-results-row{display:flex;align-items:flex-start;padding:9px 0;border-bottom:1px solid #f0f0f0;font-size:var(--font-size-base);transition:background-color .2s}
            body.anubis-dark-theme .anubis-results-header,body.anubis-dark-theme .anubis-results-row{border-bottom-color:var(--color-border-light)}
            .anubis-results-header{font-weight:700;border-bottom-width:2px;border-bottom-color:var(--color-border-light)}
            .anubis-results-header .col-title{text-align:center}
            .anubis-results-row:last-child{border-bottom:none}
            .anubis-results-row:not(.no-results):hover{background-color:var(--color-row-hover)}
            .anubis-results-row a{text-decoration:none;color:var(--color-text-link)}
            .anubis-results-row a:hover{text-decoration:underline}
            .anubis-comment-count{color:var(--color-text-secondary);font-weight:400;padding-left:4px}
            .col-no{width:70px;text-align:center;flex-shrink:0}
            .col-title{flex-grow:1;text-align:left;word-break:break-all}
            .col-date{width:110px;text-align:center;flex-shrink:0}
            .col-views,.col-reco{width:60px;text-align:center;flex-shrink:0}
            .anubis-settings-item{display:flex;justify-content:space-between;align-items:center}
            .anubis-settings-item-sub{padding-left:15px;margin-top:15px}
            #anubis-settings-save-status{color:var(--color-text-secondary);font-size:var(--font-size-base);margin-right:auto;display:none}
            #anubis-top-btn-wrapper{display:inline-flex;align-items:center;gap:2px;vertical-align:top}
            .anubis-top-icon-btn{display:flex;align-items:center;justify-content:center;width:21px;height:21px;box-sizing:border-box;border:1px solid var(--color-border-dark);background-color:var(--color-bg);border-radius:0;cursor:pointer;color:var(--color-text-secondary)}
            .anubis-top-icon-btn svg{color:var(--color-text-secondary)}
            body.anubis-dark-theme .anubis-top-icon-btn{border-color:var(--color-border)}
            body.anubis-dark-theme .anubis-top-icon-btn svg{color:var(--color-text-secondary)}
            .anubis-filter-group{display:flex;align-items:center;justify-content:flex-start;gap:10px}
            .anubis-filter-group-label-wrapper{display:flex;align-items:center;gap:5px}
            .anubis-filter-group-label-wrapper .anubis-form-label{margin-bottom:0;cursor:pointer}
            .anubis-filter-input{padding:4px 8px!important}
            .anubis-filter-input:disabled{background-color:var(--color-row-hover)!important;opacity:.7}
            .anubis-tooltip-wrapper{position:relative;display:inline-flex;align-items:center}
            .anubis-tooltip-icon{cursor:pointer;font-weight:700;color:var(--color-text-secondary);border:1px solid var(--color-text-secondary);border-radius:50%;width:14px;height:14px;display:inline-flex;justify-content:center;align-items:center;font-size:10px;line-height:1}
            .anubis-tooltip-text{visibility:hidden;width:350px;background-color:var(--color-bg);color:var(--color-text-primary);text-align:left;border-radius:4px;padding:8px 12px;position:absolute;z-index:10002;bottom:150%;left:50%;transform:translateX(-50%);opacity:0;transition:opacity .3s;font-size:var(--font-size-base);font-weight:400;line-height:1.4;box-shadow:0 2px 5px rgba(0,0,0,.2);border:1px solid var(--color-border-dark)}
            .anubis-tooltip-text::before{content:"";position:absolute;top:100%;left:50%;margin-left:-6px;border-width:6px;border-style:solid;border-color:var(--color-border-dark) transparent transparent transparent;z-index:10001}
            .anubis-tooltip-text::after{content:"";position:absolute;top:100%;left:50%;margin-left:-5px;border-width:5px;border-style:solid;border-color:var(--color-bg) transparent transparent transparent;z-index:10002}
            .anubis-tooltip-wrapper:hover .anubis-tooltip-text{visibility:visible;opacity:1}
        `,
    };

    // --- TEMPLATES ---
    const TEMPLATES = {
        _create: {
            formGroup: ({ id, label, description, contentHTML, style = '' }) => `
                <div id="${id || ''}" class="anubis-form-group" style="${style}">
                    <label class="anubis-form-label">${label}</label>
                    ${description ? `<p class="anubis-form-description">${description}</p>` : ''}
                    ${contentHTML}
                </div>`,
            inputGroup: (contentHTML) => `<div class="anubis-input-group">${contentHTML}</div>`,
            button: ({ id, text, classes = [] }) => `<button id="${id}" class="anubis-btn ${classes.join(' ')}">${text}</button>`,
            slider: ({ id, min, max, value, unit }) => `
                <div class="anubis-slider-container">
                    <input type="range" id="${id}" min="${min}" max="${max}" value="${value}" class="anubis-slider">
                    <span id="${id}-value" class="anubis-slider-value">${value}${unit}</span>
                </div>`,
            gallerySelector: (idPrefix) => `
                <div style="width: 70%;">
                    <select class="anubis-select-input" id="${idPrefix}-gallery-select" style="width: 100%;"></select>
                </div>
                <div id="${idPrefix}-gallery-direct-input-container" style="display: none; margin-top: 10px; width: 100%;">
                    <div style="width: 70%; margin-bottom: 10px;">
                        <input type="text" class="anubis-text-input" id="${idPrefix}-gallery-direct-input" placeholder="갤러리 ID (갤러리명X)" style="width: 100%;">
                    </div>
                    <div class="anubis-radio-group">
                        <label><input type="radio" name="${idPrefix}-gallery-type" value="board" checked> 정식</label>
                        <label><input type="radio" name="${idPrefix}-gallery-type" value="mgallery"> 마이너</label>
                        <label><input type="radio" name="${idPrefix}-gallery-type" value="mini"> 미니</label>
                        <label><input type="radio" name="${idPrefix}-gallery-type" value="person"> 인물</label>
                    </div>
                </div>`,
            tooltip: (text) => `
                <div class="anubis-tooltip-wrapper">
                    <span class="anubis-tooltip-icon">i</span>
                    <div class="anubis-tooltip-text">${text}</div>
                </div>`,
        },

        chamber({ id, title, bodyHTML, footerHTML, extraClasses = [] }) {
            return `
                <div id="${id}" class="${CONFIG.CLASSES.MODAL} ${extraClasses.join(' ')}">
                    <div class="anubis-chamber-header">
                        <span>${title}</span>
                        <button class="anubis-chamber-close">&times;</button>
                    </div>
                    <div class="anubis-chamber-body">${bodyHTML}</div>
                    ${footerHTML ? `<div class="anubis-chamber-footer">${footerHTML}</div>` : ''}
                </div>`;
        },

        searchChamber({ userInfo }) {
            const C = this._create;
            const tooltipText = '설정한 날짜가 지나치게 과거일 경우<br>페이지 이동을 통해 대략적인 페이지 위치를 파악한 후에<br>검색을 시작할 페이지를 설정해 주세요';
            const body = `
                ${C.formGroup({ label: '글쓴이', contentHTML: `<div class="anubis-user-info">${userInfo.displayName}</div>` })}
                ${C.formGroup({ label: '갤러리 선택', contentHTML: C.gallerySelector('anubis-search') })}
                ${C.formGroup({
                    label: '검색 범위 유형',
                    description: '검색 범위를 설정할 유형입니다',
                    contentHTML: `<div style="width: 50%;"><select class="anubis-select-input" id="anubis-search-type"><option value="page">페이지</option><option value="date">날짜</option></select></div>`
                })}
                ${C.formGroup({
                    id: 'anubis-page-range-options',
                    label: '페이지 범위',
                    description: `설정한 페이지의 게시글을 검색합니다 (최대 ${CONFIG.CONSTANTS.MAX_SEARCH_PAGES}페이지)`,
                    contentHTML: C.inputGroup(`<input type="number" class="anubis-page-input" id="anubis-start-page" min="1" value="1"><span>-</span><input type="number" class="anubis-page-input" id="anubis-end-page" min="1">`)
                })}
                <div id="anubis-date-options-wrapper" style="display: none;">
                    ${C.formGroup({
                        label: `<div style="display: flex; align-items: center; gap: 5px;"><b>시작 페이지</b>${C.tooltip(tooltipText)}</div>`,
                        description: '검색을 시작할 페이지를 설정합니다',
                        contentHTML: `<div style="width: 25%;"><input type="number" class="anubis-page-input" id="anubis-date-start-page" min="1" value="1" style="width: 100%;"></div>`
                    })}
                    ${C.formGroup({
                        label: '날짜 범위',
                        description: `설정한 날짜의 결과를 출력합니다 (최대 ${CONFIG.CONSTANTS.MAX_DATE_SEARCH_DAYS}일)`,
                        contentHTML: C.inputGroup(`<input type="date" class="anubis-date-input" id="anubis-start-date"><span>-</span><input type="date" class="anubis-date-input" id="anubis-end-date">`)
                    })}
                </div>`;
            const footer = `<div class="anubis-button-group" style="justify-content: flex-end; width: 100%;">
                    ${C.button({ id: 'anubis-search-confirm', text: '검색', classes: ['anubis-confirm-btn'] })}
                    ${C.button({ id: 'anubis-search-cancel', text: '취소', classes: ['anubis-cancel-btn'] })}
                </div>`;
            return this.chamber({ id: CONFIG.IDS.SEARCH_MODAL, title: '고급 작성글 검색', bodyHTML: body, footerHTML: footer });
        },

        directSearchChamber() {
            const C = this._create;
            const tooltipText = '설정한 날짜가 지나치게 과거일 경우<br>페이지 이동을 통해 대략적인 페이지 위치를 파악한 후에<br>검색을 시작할 페이지를 설정해 주세요';
            const body = `
                ${C.formGroup({
                    label: '글쓴이',
                    contentHTML: `<input type="text" id="anubis-direct-search-user-input" class="anubis-text-input" placeholder="식별 코드 또는 IP를 입력해주세요">`
                })}
                ${C.formGroup({ label: '갤러리 선택', contentHTML: C.gallerySelector('anubis-direct-search') })}
                ${C.formGroup({
                    label: '검색 범위 유형',
                    description: '검색 범위를 설정할 유형입니다',
                    contentHTML: `<div style="width: 50%;"><select class="anubis-select-input" id="anubis-direct-search-type"><option value="page">페이지</option><option value="date">날짜</option></select></div>`
                })}
                ${C.formGroup({
                    id: 'anubis-direct-page-range-options',
                    label: '페이지 범위',
                    description: `설정한 페이지의 게시글을 검색합니다 (최대 ${CONFIG.CONSTANTS.MAX_SEARCH_PAGES}페이지)`,
                    contentHTML: C.inputGroup(`<input type="number" class="anubis-page-input" id="anubis-direct-start-page" min="1" value="1"><span>-</span><input type="number" class="anubis-page-input" id="anubis-direct-end-page" min="1">`)
                })}
                <div id="anubis-direct-date-options-wrapper" style="display: none;">
                     ${C.formGroup({
                        label: `<div style="display: flex; align-items: center; gap: 5px;"><b>시작 페이지</b>${C.tooltip(tooltipText)}</div>`,
                        description: '검색을 시작할 페이지를 설정합니다',
                        contentHTML: `<div style="width: 25%;"><input type="number" class="anubis-page-input" id="anubis-direct-date-start-page" min="1" value="1" style="width: 100%;"></div>`
                    })}
                    ${C.formGroup({
                        label: '날짜 범위',
                        description: `설정한 날짜의 결과를 출력합니다 (최대 ${CONFIG.CONSTANTS.MAX_DATE_SEARCH_DAYS}일)`,
                        contentHTML: C.inputGroup(`<input type="date" class="anubis-date-input" id="anubis-direct-start-date"><span>-</span><input type="date" class="anubis-date-input" id="anubis-direct-end-date">`)
                    })}
                </div>`;
            const footer = `<div class="anubis-button-group" style="justify-content: flex-end; width: 100%;">
                    ${C.button({ id: 'anubis-direct-search-confirm', text: '검색', classes: ['anubis-confirm-btn'] })}
                    ${C.button({ id: 'anubis-direct-search-cancel', text: '취소', classes: ['anubis-cancel-btn'] })}
                </div>`;
            return this.chamber({ id: CONFIG.IDS.DIRECT_SEARCH_MODAL, title: '고급 작성글 검색', bodyHTML: body, footerHTML: footer });
        },

        filterChamber() {
            const C = this._create;
            const tooltipText = '설정한 날짜가 지나치게 과거일 경우<br>페이지 이동을 통해 대략적인 페이지 위치를 파악한 후에<br>검색을 시작할 페이지를 설정해 주세요';
            const createFilterGroup = (id, label) => `
                <div class="anubis-form-group anubis-filter-group" style="margin-bottom: 15px;">
                    <div class="anubis-filter-group-label-wrapper">
                         <input type="checkbox" id="anubis-filter-${id}-enabled" style="transform: scale(1.2);">
                         <label for="anubis-filter-${id}-enabled" class="anubis-form-label">${label}</label>
                    </div>
                    <div class="anubis-input-group">
                        <input type="number" class="anubis-page-input anubis-filter-input" id="anubis-filter-${id}" min="0" placeholder="0">
                        <span>이상</span>
                    </div>
                </div>`;

            const body = `
                <div class="anubis-form-group" style="display: flex; align-items: center; justify-content: flex-start; margin-bottom: 25px;">
                    <label class="anubis-form-label" style="margin-bottom: 0; margin-right: 10px;">개념글 제외</label>
                    <input type="checkbox" id="anubis-filter-exclude-gallchu" style="transform: scale(1.2);">
                </div>
                ${createFilterGroup('reco', '추천 수')}
                ${createFilterGroup('comments', '댓글 수')}
                ${createFilterGroup('views', '조회 수')}
                ${C.formGroup({
                    label: '검색 범위 유형',
                    description: '검색 범위를 설정할 유형입니다',
                    contentHTML: `<div style="width: 50%;"><select class="anubis-select-input" id="anubis-filter-search-type"><option value="page">페이지</option><option value="date">날짜</option></select></div>`
                })}
                ${C.formGroup({
                    id: 'anubis-filter-page-range-options',
                    label: '페이지 범위',
                    description: `설정한 페이지의 게시글을 검색합니다 (최대 ${CONFIG.CONSTANTS.MAX_SEARCH_PAGES}페이지)`,
                    contentHTML: C.inputGroup(`<input type="number" class="anubis-page-input" id="anubis-filter-start-page" min="1" value="1"><span>-</span><input type="number" class="anubis-page-input" id="anubis-filter-end-page" min="1">`)
                })}
                <div id="anubis-filter-date-options-wrapper" style="display: none;">
                    ${C.formGroup({
                        label: `<div style="display: flex; align-items: center; gap: 5px;"><b>시작 페이지</b>${C.tooltip(tooltipText)}</div>`,
                        description: '검색을 시작할 페이지를 설정합니다',
                        contentHTML: `<div style="width: 25%;"><input type="number" class="anubis-page-input" id="anubis-filter-date-start-page" min="1" value="1" style="width: 100%;"></div>`
                    })}
                    ${C.formGroup({
                        label: '날짜 범위',
                        description: `설정한 날짜의 결과를 출력합니다 (최대 ${CONFIG.CONSTANTS.MAX_DATE_SEARCH_DAYS}일)`,
                        contentHTML: C.inputGroup(`<input type="date" class="anubis-date-input" id="anubis-filter-start-date"><span>-</span><input type="date" class="anubis-date-input" id="anubis-filter-end-date">`)
                    })}
                </div>`;
            const footer = `<div class="anubis-button-group" style="justify-content: flex-end; width: 100%;">
                    ${C.button({ id: 'anubis-filter-confirm', text: '검색', classes: ['anubis-confirm-btn'] })}
                    ${C.button({ id: 'anubis-filter-cancel', text: '취소', classes: ['anubis-cancel-btn'] })}
                </div>`;
            return this.chamber({ id: CONFIG.IDS.FILTER_MODAL, title: '필터링 검색', bodyHTML: body, footerHTML: footer });
        },

        resultsChamber(data) {
            const { isLoading } = data;
            const body = isLoading ? this._resultsLoadingBody(data) : this._resultsDoneBody(data);
            return this.chamber({
                id: CONFIG.IDS.RESULTS_MODAL,
                title: isLoading ? '검색 중' : '검색 결과',
                bodyHTML: body,
                footerHTML: isLoading ? '' : `<div class="anubis-button-group"></div>`,
                extraClasses: isLoading ? ['loading-state'] : []
            });
        },

        _resultsLoadingBody({ userInfo, statusText, progress, galleryDisplayName }) {
            const infoLine = userInfo ? `${CORE_LOGIC.escapeHtml(galleryDisplayName)} → ${CORE_LOGIC.escapeHtml(userInfo.displayName)}` : `${CORE_LOGIC.escapeHtml(galleryDisplayName)}`;
            return `
                <div class="anubis-status-container loading">
                    <p id="${CONFIG.IDS.SEARCH_INFO_TEXT}" class="anubis-search-info-line">${infoLine}</p>
                    <p class="anubis-status-text">${statusText}</p>
                    <div class="anubis-progress-container">
                        <div class="anubis-progress-wrapper">
                            <div class="anubis-progress-bar" style="width: ${progress}%;"></div>
                            <span class="anubis-progress-text">${progress}%</span>
                        </div>
                    </div>
                </div>`;
        },

        _resultsDoneBody({ posts, statusText, galleryDisplayName, startDate, endDate, userInfo }) {
            const infoLine = userInfo ? `${CORE_LOGIC.escapeHtml(galleryDisplayName)} → ${CORE_LOGIC.escapeHtml(userInfo.displayName)}` : `${CORE_LOGIC.escapeHtml(galleryDisplayName)}`;
            const formatDate = (dateStr) => dateStr.replaceAll('-', '.').slice(2);
            const dateFilterLine = (startDate || endDate) ? `<p class="anubis-date-filter-line">(${startDate ? formatDate(startDate) : ''} ~ ${endDate ? formatDate(endDate) : ''})</p>` : '';
            const resultsHeader = `
                <div class="anubis-results-header">
                    <div class="col-no">번호</div><div class="col-title">제목</div><div class="col-date">작성일</div>
                    <div class="col-views">조회</div><div class="col-reco">추천</div>
                </div>`;
            const postListHTML = posts.length > 0
                ? posts.map(post => {
                    const d = new Date(post.timestamp);
                    const pad = (num) => String(num).padStart(2, '0');
                    const postDate = `${String(d.getFullYear()).slice(2)}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
                    return `
                        <div class="anubis-results-row">
                            <div class="col-no">${post.postNo}</div>
                            <div class="col-title"><a href="${post.url}" target="_blank" rel="noopener noreferrer">${CORE_LOGIC.escapeHtml(post.title)}</a><span class="anubis-comment-count">${post.commentCount > 0 ? `[${post.commentCount}]` : ''}</span></div>
                            <div class="col-date">${postDate}</div>
                            <div class="col-views">${post.views}</div>
                            <div class="col-reco">${post.reco}</div>
                        </div>`;
                }).join('')
                : '<div class="anubis-results-row no-results" style="justify-content:center;">검색 결과가 없습니다.</div>';
            return `
                <div class="anubis-status-container finished">
                    <p class="anubis-search-info-line">${infoLine}</p>
                    ${dateFilterLine}
                    <p class="anubis-status-text">${statusText}</p>
                </div>
                <div class="anubis-results-list">${resultsHeader}${postListHTML}</div>`;
        },

        settingsChamber() {
            const C = this._create;
            const body = `
                <div class="anubis-settings-item-sub">
                    ${C.formGroup({
                        label: '기본 검색 범위 유형',
                        contentHTML: `<div style="width: 50%;"><select class="anubis-select-input" id="anubis-default-search-type"><option value="page">페이지</option><option value="date">날짜</option></select></div>`
                    })}
                    ${C.formGroup({
                        label: '기본 검색 페이지 범위',
                        contentHTML: C.slider({ id: 'anubis-default-end-page', min: 1, max: CONFIG.CONSTANTS.MAX_SEARCH_PAGES, value: 20, unit: 'p' })
                    })}
                    ${C.formGroup({
                        label: '기본 검색 날짜 범위',
                        contentHTML: C.slider({ id: 'anubis-default-date-range', min: 1, max: CONFIG.CONSTANTS.MAX_DATE_SEARCH_DAYS, value: 1, unit: '일' })
                    })}
                </div>`;
            const footer = `
                ${C.button({ id: 'anubis-reset-settings', text: '설정 초기화', classes: ['anubis-cancel-btn'] })}
                <div class="anubis-button-group" style="align-items: center;">
                    <span id="anubis-settings-save-status"></span>
                    ${C.button({ id: 'anubis-settings-save', text: '저장', classes: ['anubis-confirm-btn'] })}
                </div>`;
            return this.chamber({ id: CONFIG.IDS.SETTINGS_MODAL, title: 'ANUBIS 설정', bodyHTML: body, footerHTML: footer });
        },
    };

    // --- DOM UTILITIES ---
    const DOM_UTILS = {
        injectStyles: () => {
            if (document.getElementById(CONFIG.IDS.STYLES)) return;
            const styleSheet = document.createElement('style');
            styleSheet.id = CONFIG.IDS.STYLES;
            styleSheet.innerText = CONFIG.STYLES;
            document.head.appendChild(styleSheet);
        },

        isDarkMode: () => !!document.getElementById('css-darkmode'),

        updateTheme: () => document.body.classList.toggle(CONFIG.CLASSES.DARK_THEME, DOM_UTILS.isDarkMode()),

        showChamber(id, innerHTML, onClose) {
            this.hideChamber(id);
            const chamberWrapper = document.createElement('div');
            chamberWrapper.innerHTML = innerHTML;
            const newChamber = chamberWrapper.firstElementChild;
            document.body.appendChild(newChamber);
            newChamber.style.display = 'block';
            newChamber.querySelector('.anubis-chamber-close')?.addEventListener('click', () => {
                let shouldClose = true;
                if (onClose) shouldClose = onClose();
                if (shouldClose !== false) this.hideChamber(id);
            });
            return newChamber;
        },

        hideChamber: (id) => document.getElementById(id)?.remove(),

        createAdvancedSearchButton(popupUlElement) {
            const listItem = document.createElement('li');
            listItem.id = CONFIG.IDS.ADVANCED_SEARCH_BTN;
            listItem.className = 'bg_grey';
            listItem.innerHTML = `<a href="javascript:void(0);">고급 검색<em class="sp_img icon_go"></em></a>`;
            listItem.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                CORE_LOGIC.openSearchChamber(popupUlElement);
                popupUlElement.closest('.user_data_lyr')?.style.setProperty('display', 'none');
            });
            const target = Array.from(popupUlElement.querySelectorAll('li a')).find(a => a.textContent.trim() === '작성글 검색');
            if (target) target.parentElement.before(listItem);
            else popupUlElement.appendChild(listItem);
        },

        createTopButtons() {
            const rightBox = document.querySelector(CONFIG.SELECTORS.TOP_AREA_RIGHT);
            if (!rightBox || document.getElementById(CONFIG.IDS.TOP_BTN_WRAPPER)) return;

            const anchorDiv = rightBox.querySelector('.output_array');
            const wrapper = document.createElement('div');
            wrapper.id = CONFIG.IDS.TOP_BTN_WRAPPER;

            const searchBtn = document.createElement('span');
            searchBtn.id = CONFIG.IDS.QUICK_SEARCH_BTN;
            searchBtn.className = CONFIG.CLASSES.TOP_ICON_BTN;
            searchBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 48 48" fill="currentColor"><path d="m47.008 41.814-14.992-14.685c1.754-2.77 2.68-5.968 2.68-9.262 0-9.565-7.783-17.349-17.348-17.349-9.565 0-17.348 7.783-17.348 17.349 0 9.565 7.783 17.346 17.348 17.346 3.654 0 7.151-1.133 10.127-3.281l14.908 14.605c.621.609 1.441.945 2.313.945.896 0 1.736-.354 2.363-.994 1.274-1.302 1.252-3.398-.051-4.674zm-29.66-11.396c-6.92 0-12.55-5.631-12.55-12.551s5.63-12.55 12.55-12.55 12.549 5.63 12.549 12.55c0 3.259-1.242 6.347-3.5 8.696-2.39 2.486-5.604 3.855-9.049 3.855z"></path></svg>`;
            searchBtn.addEventListener('click', () => CORE_LOGIC.openDirectSearchChamber());

            const filterBtn = document.createElement('span');
            filterBtn.id = CONFIG.IDS.QUICK_FILTER_BTN;
            filterBtn.className = CONFIG.CLASSES.TOP_ICON_BTN;
            filterBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M21.877,2.5186A1,1,0,0,0,21,2H3a1,1,0,0,0-.8437,1.5371L9,14.291V19a1,1,0,0,0,.5527.8945l4,2A1,1,0,0,0,15,21V14.291L21.8438,3.5371A1.0021,1.0021,0,0,0,21.877,2.5186ZM13.1563,13.4629A1.0062,1.0062,0,0,0,13,14v5.3818l-2-1V14a1.007,1.007,0,0,0-.1562-.5371L4.8213,4H19.1787Z"></path></svg>`;
            filterBtn.addEventListener('click', () => CORE_LOGIC.openFilterChamber());

            wrapper.appendChild(searchBtn);
            wrapper.appendChild(filterBtn);

            wrapper.style.marginRight = '2px';
            if (anchorDiv) {
                anchorDiv.style.display = 'flex';
                anchorDiv.style.alignItems = 'center';
                anchorDiv.prepend(wrapper);
            } else {
                wrapper.style.float = 'left';
                rightBox.prepend(wrapper);
            }
        },
    };

    // --- DATA PARSERS ---
    const DATA_PARSER = {
        extractUserInfo(popupUlElement) {
            const writerElement = popupUlElement.closest(CONFIG.SELECTORS.POST_CONTAINER)?.querySelector(CONFIG.SELECTORS.WRITER_INFO);
            if (!writerElement) return null;
            const nick = writerElement.dataset.nick?.trim();
            const uid = writerElement.dataset.uid?.trim();
            const ip = writerElement.dataset.ip?.trim();
            const filterKey = uid ? `id:${uid}` : (ip ? `ip:${ip}` : null);
            if (!filterKey) return null;
            let displayName = '정보 없음';
            if (uid) displayName = `${nick} (${uid})`;
            else if (ip) displayName = (nick && nick !== ip) ? `${nick} (${ip})` : `유동 (${ip})`;
            return { displayName, filterKey, nick };
        },

        getRecentGalleries() {
            const list = document.querySelector(CONFIG.SELECTORS.RECENT_GALLERY_LIST);
            if (!list) return [];
            const galleries = [];
            list.querySelectorAll(CONFIG.SELECTORS.RECENT_GALLERY_LINK).forEach(link => {
                try {
                    const url = new URL(link.href);
                    const id = url.searchParams.get('id');
                    const type = url.pathname.split('/').filter(Boolean)[0];
                    const name = link.textContent.trim();
                    if (id && name) galleries.push({ id, name, type });
                } catch (e) { console.error("ANUBIS: 갤러리 정보 파싱 중 오류", e); }
            });
            return galleries;
        },

        parseGalleryNameFromHtml(html) {
            const doc = new DOMParser().parseFromString(html, "text/html");
            const el = doc.querySelector(CONFIG.SELECTORS.CURRENT_GALLERY_NAME);
            if (!el) return null;
            const clone = el.cloneNode(true);
            clone.querySelectorAll('em, span').forEach(e => e.remove());
            return clone.textContent.trim();
        },

        parseUserPostsFromHtml(html, targetFilterKey, galleryId, galleryType) {
            const doc = new DOMParser().parseFromString(html, "text/html");
            const posts = [];
            const path = galleryType === 'board' ? 'board' : `${galleryType}/board`;
            doc.querySelectorAll(CONFIG.SELECTORS.POST_ROW).forEach(tr => {
                const writerEl = tr.querySelector(CONFIG.SELECTORS.WRITER_INFO);
                if (!writerEl) return;
                const uid = writerEl.dataset.uid?.trim();
                const ip = writerEl.dataset.ip?.trim();
                const currentKey = uid ? `id:${uid}` : (ip ? `ip:${ip}` : null);
                if (currentKey !== targetFilterKey) return;

                const titleEl = tr.querySelector(CONFIG.SELECTORS.POST_TITLE);
                const postNo = tr.dataset.no;
                const timestamp = tr.querySelector(CONFIG.SELECTORS.POST_DATE)?.title;
                if (!titleEl || !postNo || !timestamp) return;

                posts.push({
                    title: titleEl.textContent.trim(),
                    url: `https://gall.dcinside.com/${path}/view/?id=${galleryId}&no=${postNo}`,
                    timestamp, postNo,
                    views: tr.querySelector(CONFIG.SELECTORS.POST_VIEWS)?.textContent.trim() ?? '0',
                    reco: tr.querySelector(CONFIG.SELECTORS.POST_RECOMMEND)?.textContent.trim() ?? '0',
                    commentCount: parseInt(tr.querySelector(CONFIG.SELECTORS.POST_COMMENT_COUNT)?.textContent.replace(/[\[\]]/g, '') ?? '0', 10) || 0,
                });
            });
            return posts;
        },

        parseAllPostsFromHtml(html, galleryId, galleryType) {
            const doc = new DOMParser().parseFromString(html, "text/html");
            const posts = [];
            const path = galleryType === 'board' ? 'board' : `${galleryType}/board`;
            doc.querySelectorAll(CONFIG.SELECTORS.POST_ROW).forEach(tr => {
                const titleEl = tr.querySelector(CONFIG.SELECTORS.POST_TITLE);
                const postNo = tr.dataset.no;
                const timestamp = tr.querySelector(CONFIG.SELECTORS.POST_DATE)?.title;
                if (!titleEl || !postNo || !timestamp) return;

                posts.push({
                    title: titleEl.textContent.trim(),
                    url: `https://gall.dcinside.com/${path}/view/?id=${galleryId}&no=${postNo}`,
                    timestamp, postNo,
                    views: parseInt(tr.querySelector(CONFIG.SELECTORS.POST_VIEWS)?.textContent.trim() ?? '0', 10),
                    reco: parseInt(tr.querySelector(CONFIG.SELECTORS.POST_RECOMMEND)?.textContent.trim() ?? '0', 10),
                    commentCount: parseInt(tr.querySelector(CONFIG.SELECTORS.POST_COMMENT_COUNT)?.textContent.replace(/[\[\]]/g, '') ?? '0', 10) || 0,
                    dataType: tr.dataset.type,
                });
            });
            return posts;
        },
    };

    // --- CORE LOGIC ---
    const CORE_LOGIC = {
        settings: {},
        isSettingsDirty: false,
        currentUserInfo: null,
        isSearchCancelled: false,

        async initialize() {
            DOM_UTILS.injectStyles();
            await this.loadSettings();
            this.setupMenuCommands();
            this.setupObservers();
            if (window.location.href.includes('/board/lists')) {
                const interval = setInterval(() => {
                    if (document.querySelector(CONFIG.SELECTORS.TOP_AREA_RIGHT)) {
                        DOM_UTILS.createTopButtons();
                        clearInterval(interval);
                    }
                }, 100);
                setTimeout(() => clearInterval(interval), 10000);
            }
            DOM_UTILS.updateTheme();
        },

        async loadSettings() {
            const S = CONFIG.STORAGE_KEYS;
            this.settings.defaultSearchType = await GM_getValue(S.DEFAULT_SEARCH_TYPE, 'page');
            this.settings.defaultEndPage = await GM_getValue(S.DEFAULT_END_PAGE, 20);
            this.settings.defaultDateRange = await GM_getValue(S.DEFAULT_DATE_RANGE, 1);
        },

        setupMenuCommands() {
            GM_registerMenuCommand('고급 검색 기본값 설정', () => this.openSettingsChamber());
        },

        setupObservers() {
            new MutationObserver(() => DOM_UTILS.updateTheme()).observe(document.head, { childList: true });
            new MutationObserver((mutations) => {
                for (const mutation of mutations) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType !== Node.ELEMENT_NODE) continue;
                        const popup = node.querySelector(CONFIG.SELECTORS.USER_POPUP_LIST) || (node.matches(CONFIG.SELECTORS.USER_POPUP_LIST) ? node : null);
                        if (popup && !popup.querySelector(`#${CONFIG.IDS.ADVANCED_SEARCH_BTN}`)) {
                            DOM_UTILS.createAdvancedSearchButton(popup);
                        }
                    }
                }
            }).observe(document.body, { childList: true, subtree: true });
        },

        openSearchChamber(popupUlElement) {
            const userInfo = DATA_PARSER.extractUserInfo(popupUlElement);
            if (!userInfo || !userInfo.filterKey) return alert('사용자 정보를 가져오는 데 실패했습니다.');
            this.currentUserInfo = userInfo;
            const modalHTML = TEMPLATES.searchChamber({ userInfo });
            const chamber = DOM_UTILS.showChamber(CONFIG.IDS.SEARCH_MODAL, modalHTML);
            this.bindSearchChamberEvents(chamber);
        },

        openDirectSearchChamber() {
            const modalHTML = TEMPLATES.directSearchChamber();
            const chamber = DOM_UTILS.showChamber(CONFIG.IDS.DIRECT_SEARCH_MODAL, modalHTML);
            this.bindDirectSearchChamberEvents(chamber);
        },

        async openFilterChamber() {
            const modalHTML = TEMPLATES.filterChamber();
            const chamber = DOM_UTILS.showChamber(CONFIG.IDS.FILTER_MODAL, modalHTML);
            await this.bindFilterChamberEvents(chamber);
        },

        bindSearchChamberEvents(chamber) {
            this._bindGallerySelector(chamber, 'anubis-search');
            const searchTypeSelect = chamber.querySelector('#anubis-search-type');
            const pageRangeOptions = chamber.querySelector('#anubis-page-range-options');
            const dateOptionsWrapper = chamber.querySelector('#anubis-date-options-wrapper');

            searchTypeSelect.addEventListener('change', () => {
                const isPage = searchTypeSelect.value === 'page';
                pageRangeOptions.style.display = isPage ? 'block' : 'none';
                dateOptionsWrapper.style.display = isPage ? 'none' : 'block';
            });

            chamber.querySelector('#anubis-end-page').value = this.settings.defaultEndPage;
            const today = new Date();
            chamber.querySelector('#anubis-end-date').value = today.toISOString().split('T')[0];
            today.setDate(today.getDate() - (this.settings.defaultDateRange - 1));
            chamber.querySelector('#anubis-start-date').value = today.toISOString().split('T')[0];
            searchTypeSelect.value = this.settings.defaultSearchType;
            searchTypeSelect.dispatchEvent(new Event('change'));

            chamber.querySelector('#anubis-search-confirm').addEventListener('click', () => {
                const galleryInfo = this._getGalleryInfoFromSelector(chamber, 'anubis-search');
                if (!galleryInfo.galleryId) return alert("갤러리를 선택하거나 ID를 입력해 주세요.");
                const baseParams = { userInfo: this.currentUserInfo, ...galleryInfo };
                DOM_UTILS.hideChamber(CONFIG.IDS.SEARCH_MODAL);
                this._executeSearch(chamber, baseParams);
            });
            chamber.querySelector('#anubis-search-cancel').addEventListener('click', () => DOM_UTILS.hideChamber(CONFIG.IDS.SEARCH_MODAL));
        },

        bindDirectSearchChamberEvents(chamber) {
            this._bindGallerySelector(chamber, 'anubis-direct-search');
            const searchTypeSelect = chamber.querySelector('#anubis-direct-search-type');
            const pageRangeOptions = chamber.querySelector('#anubis-direct-page-range-options');
            const dateOptionsWrapper = chamber.querySelector('#anubis-direct-date-options-wrapper');

            searchTypeSelect.addEventListener('change', () => {
                const isPage = searchTypeSelect.value === 'page';
                pageRangeOptions.style.display = isPage ? 'block' : 'none';
                dateOptionsWrapper.style.display = isPage ? 'none' : 'block';
            });

            chamber.querySelector('#anubis-direct-end-page').value = this.settings.defaultEndPage;
            const today = new Date();
            chamber.querySelector('#anubis-direct-end-date').value = today.toISOString().split('T')[0];
            today.setDate(today.getDate() - (this.settings.defaultDateRange - 1));
            chamber.querySelector('#anubis-direct-start-date').value = today.toISOString().split('T')[0];
            searchTypeSelect.value = this.settings.defaultSearchType;
            searchTypeSelect.dispatchEvent(new Event('change'));

            chamber.querySelector('#anubis-direct-search-confirm').addEventListener('click', () => {
                const userInput = chamber.querySelector('#anubis-direct-search-user-input').value.trim();
                if (!userInput) return alert("글쓴이 정보를 입력해 주세요.");

                let filterKey, displayName;
                if (/^(\d{1,3}\.){1,3}\d{1,3}$/.test(userInput)) {
                    filterKey = `ip:${userInput}`;
                    displayName = userInput;
                } else {
                    const match = userInput.match(/(.+)\s\((.+)\)/);
                    if (match) {
                        filterKey = `id:${match[2]}`;
                        displayName = userInput;
                    } else {
                        filterKey = `id:${userInput}`;
                        displayName = userInput;
                    }
                }

                const galleryInfo = this._getGalleryInfoFromSelector(chamber, 'anubis-direct-search');
                if (!galleryInfo.galleryId) return alert("갤러리를 선택하거나 ID를 입력해 주세요.");
                const baseParams = { userInfo: { filterKey, displayName }, ...galleryInfo };
                DOM_UTILS.hideChamber(CONFIG.IDS.DIRECT_SEARCH_MODAL);
                this._executeSearch(chamber, baseParams);
            });
            chamber.querySelector('#anubis-direct-search-cancel').addEventListener('click', () => DOM_UTILS.hideChamber(CONFIG.IDS.DIRECT_SEARCH_MODAL));
        },

        async bindFilterChamberEvents(chamber) {
            const S = CONFIG.STORAGE_KEYS;
            const recoEnabledCheck = chamber.querySelector('#anubis-filter-reco-enabled');
            const commentsEnabledCheck = chamber.querySelector('#anubis-filter-comments-enabled');
            const viewsEnabledCheck = chamber.querySelector('#anubis-filter-views-enabled');
            const recoInput = chamber.querySelector('#anubis-filter-reco');
            const commentsInput = chamber.querySelector('#anubis-filter-comments');
            const viewsInput = chamber.querySelector('#anubis-filter-views');

            const setupFilterGroup = async (id, checkEl, inputEl) => {
                checkEl.checked = await GM_getValue(S[`FILTER_${id.toUpperCase()}_ENABLED`], true);
                inputEl.value = await GM_getValue(S[`FILTER_MIN_${id.toUpperCase()}`], 0);
                inputEl.disabled = !checkEl.checked;
                checkEl.addEventListener('change', () => inputEl.disabled = !checkEl.checked);
            };

            chamber.querySelector('#anubis-filter-exclude-gallchu').checked = await GM_getValue(S.FILTER_EXCLUDE_GALLCHU, false);
            await Promise.all([
                setupFilterGroup('reco', recoEnabledCheck, recoInput),
                setupFilterGroup('comments', commentsEnabledCheck, commentsInput),
                setupFilterGroup('views', viewsEnabledCheck, viewsInput)
            ]);

            const searchTypeSelect = chamber.querySelector('#anubis-filter-search-type');
            const pageRangeOptions = chamber.querySelector('#anubis-filter-page-range-options');
            const dateOptionsWrapper = chamber.querySelector('#anubis-filter-date-options-wrapper');
            searchTypeSelect.addEventListener('change', () => {
                const isPage = searchTypeSelect.value === 'page';
                pageRangeOptions.style.display = isPage ? 'block' : 'none';
                dateOptionsWrapper.style.display = isPage ? 'none' : 'block';
            });

            chamber.querySelector('#anubis-filter-end-page').value = this.settings.defaultEndPage;
            const today = new Date();
            chamber.querySelector('#anubis-filter-end-date').value = today.toISOString().split('T')[0];
            today.setDate(today.getDate() - (this.settings.defaultDateRange - 1));
            chamber.querySelector('#anubis-filter-start-date').value = today.toISOString().split('T')[0];
            searchTypeSelect.value = this.settings.defaultSearchType;
            searchTypeSelect.dispatchEvent(new Event('change'));

            chamber.querySelector('#anubis-filter-confirm').addEventListener('click', async () => {
                await GM_setValue(S.FILTER_EXCLUDE_GALLCHU, chamber.querySelector('#anubis-filter-exclude-gallchu').checked);
                await GM_setValue(S.FILTER_RECO_ENABLED, recoEnabledCheck.checked);
                await GM_setValue(S.FILTER_COMMENTS_ENABLED, commentsEnabledCheck.checked);
                await GM_setValue(S.FILTER_VIEWS_ENABLED, viewsEnabledCheck.checked);
                await GM_setValue(S.FILTER_MIN_RECO, parseInt(recoInput.value, 10) || 0);
                await GM_setValue(S.FILTER_MIN_COMMENTS, parseInt(commentsInput.value, 10) || 0);
                await GM_setValue(S.FILTER_MIN_VIEWS, parseInt(viewsInput.value, 10) || 0);

                const baseParams = {
                    minReco: recoEnabledCheck.checked ? (parseInt(recoInput.value, 10) || 0) : 0,
                    minComments: commentsEnabledCheck.checked ? (parseInt(commentsInput.value, 10) || 0) : 0,
                    minViews: viewsEnabledCheck.checked ? (parseInt(viewsInput.value, 10) || 0) : 0,
                    excludeGallchu: chamber.querySelector('#anubis-filter-exclude-gallchu').checked
                };
                DOM_UTILS.hideChamber(CONFIG.IDS.FILTER_MODAL);
                this._executeSearch(chamber, baseParams, true);
            });

            chamber.querySelector('#anubis-filter-cancel').addEventListener('click', () => DOM_UTILS.hideChamber(CONFIG.IDS.FILTER_MODAL));
        },

        _executeSearch(chamber, baseParams, isFilter = false) {
            const searchType = chamber.querySelector('select[id$="-search-type"]').value;

            if (isFilter) {
                const urlParams = new URLSearchParams(window.location.search);
                const galleryId = urlParams.get('id');
                if (!galleryId) return alert('현재 갤러리 정보를 가져올 수 없습니다.');

                const galleryType = window.location.pathname.split('/').filter(Boolean)[0];
                const galleryDisplayName = DATA_PARSER.parseGalleryNameFromHtml(document.documentElement.outerHTML) || galleryId;
                baseParams = { ...baseParams, galleryId, galleryType, galleryDisplayName };
            }

            let searchParams = {};
            if (searchType === 'page') {
                const startPage = parseInt(chamber.querySelector('input[id$="-start-page"]').value, 10);
                const endPage = parseInt(chamber.querySelector('input[id$="-end-page"]').value, 10);
                if (isNaN(startPage) || isNaN(endPage) || startPage < 1 || endPage < startPage) return alert("올바른 페이지 범위를 입력해 주세요.");
                if (endPage - startPage + 1 > CONFIG.CONSTANTS.MAX_SEARCH_PAGES) return alert(`한 번에 최대 ${CONFIG.CONSTANTS.MAX_SEARCH_PAGES} 페이지만 검색할 수 있습니다.`);
                searchParams = { startPage, endPage };
                const searchFn = isFilter ? this.filterPosts : this.searchPostsByPage;
                searchFn.call(this, { ...baseParams, ...searchParams });
            } else {
                const startDate = chamber.querySelector('input[id$="-start-date"]').value;
                const endDate = chamber.querySelector('input[id$="-end-date"]').value;
                if (!startDate || !endDate) return alert("시작일과 종료일을 모두 입력해 주세요.");
                const start = new Date(startDate);
                const end = new Date(endDate);
                if (start > end) return alert("시작일은 종료일보다 이전이어야 합니다.");
                const diffDays = Math.ceil(Math.abs(end - start) / (1000 * 60 * 60 * 24)) + 1;
                if (diffDays > CONFIG.CONSTANTS.MAX_DATE_SEARCH_DAYS) return alert(`최대 ${CONFIG.CONSTANTS.MAX_DATE_SEARCH_DAYS}일까지 검색할 수 있습니다.`);
                const dateStartPage = parseInt(chamber.querySelector('input[id$="-date-start-page"]').value, 10) || 1;
                searchParams = { startDate, endDate, dateStartPage };
                const searchFn = isFilter ? this.filterPosts : this.searchPostsByDate;
                searchFn.call(this, { ...baseParams, ...searchParams });
            }
        },

        async _pageSearchEngine(params, pagesToFetch, processor, stopTimestamp = null) {
            this.isSearchCancelled = false;
            const totalPages = pagesToFetch.length;
            const isDateSearch = !!stopTimestamp;

            const startTimestamp = isDateSearch ? new Date(params.startDate.replace(/-/g, '/')).getTime() : 0;
            let progressEndTimestamp = isDateSearch ? Date.now() : 0;

            if (isDateSearch) {
                const firstPageNum = pagesToFetch[0];
                if (firstPageNum) {
                    const firstPageHtml = await this.fetchPageHtml(params.galleryType, params.galleryId, firstPageNum);
                    if (firstPageHtml) {
                        const firstPagePosts = DATA_PARSER.parseAllPostsFromHtml(firstPageHtml, params.galleryId, params.galleryType).filter(p => p.dataType !== 'icon_notice');
                        if (firstPagePosts.length > 0) {
                            progressEndTimestamp = new Date(firstPagePosts[0].timestamp).getTime();
                        }
                    }
                }
            }

            const totalDuration = progressEndTimestamp - startTimestamp;
            let lastScannedTimestamp = progressEndTimestamp;

            DOM_UTILS.showChamber(CONFIG.IDS.RESULTS_MODAL, TEMPLATES.resultsChamber({ ...params, isLoading: true, statusText: '검색 준비 중...', progress: 0 }), () => this.isSearchCancelled = true);

            if (params.isDirectInput) {
                const firstPageHtml = await this.fetchPageHtml(params.galleryType, params.galleryId, 1);
                if (firstPageHtml && !this.isSearchCancelled) {
                    const newGalleryName = DATA_PARSER.parseGalleryNameFromHtml(firstPageHtml);
                    if (newGalleryName) {
                        params.galleryDisplayName = newGalleryName.endsWith('갤러리') ? newGalleryName : newGalleryName + ' 갤러리';
                        const infoElement = document.getElementById(CONFIG.IDS.SEARCH_INFO_TEXT);
                        if (infoElement) infoElement.innerHTML = `${this.escapeHtml(params.galleryDisplayName)}${params.userInfo ? ` → ${this.escapeHtml(params.userInfo.displayName)}` : ''}`;
                    }
                }
            }

            const allPosts = [];
            const foundPostNos = new Set();
            let failedPages = [];
            let shouldStop = false;

            for (let i = 0; i < totalPages; i += CONFIG.CONSTANTS.SEARCH_CHUNK_SIZE) {
                if (this.isSearchCancelled || shouldStop) break;

                if (isDateSearch && i > 0 && i % 100 === 0) {
                    await new Promise(resolve => setTimeout(resolve, 3000 + Math.random() * 2000));
                }

                const chunk = pagesToFetch.slice(i, i + CONFIG.CONSTANTS.SEARCH_CHUNK_SIZE);
                const promises = chunk.map(page => this.fetchPageHtml(params.galleryType, params.galleryId, page));
                const results = await Promise.all(promises);

                results.forEach((html, index) => {
                    if (shouldStop) return;
                    if (html) {
                        const postsFromPage = processor(html);
                        postsFromPage.forEach(post => {
                            if (!foundPostNos.has(post.postNo)) {
                                foundPostNos.add(post.postNo);
                                allPosts.push(post);
                            }
                        });

                        const regularPosts = DATA_PARSER.parseAllPostsFromHtml(html, params.galleryId, params.galleryType).filter(p => p.dataType !== 'icon_notice');
                        if (regularPosts.length > 0) {
                            lastScannedTimestamp = new Date(regularPosts[regularPosts.length - 1].timestamp).getTime();
                            if (isDateSearch && lastScannedTimestamp < startTimestamp) {
                                shouldStop = true;
                            }
                        }

                    } else {
                        failedPages.push(chunk[index]);
                    }
                });

                const resultsChamber = document.getElementById(CONFIG.IDS.RESULTS_MODAL);
                if (resultsChamber && !this.isSearchCancelled) {
                    let progress = 0;
                    let statusText = '';

                    if (isDateSearch) {
                        const scannedDuration = progressEndTimestamp - lastScannedTimestamp;
                        progress = totalDuration > 0 ? Math.round((scannedDuration / totalDuration) * 100) : 0;
                        progress = Math.max(0, Math.min(progress, 100));
                        statusText = '게시글 탐색 중...';
                    } else {
                        progress = Math.round(((i + chunk.length) / totalPages) * 100);
                        statusText = `${Math.min(i + chunk.length, totalPages)}/${totalPages} 페이지 검색 중...`;
                    }

                    resultsChamber.querySelector('.anubis-status-text').textContent = statusText;
                    resultsChamber.querySelector('.anubis-progress-bar').style.width = `${progress}%`;
                    resultsChamber.querySelector('.anubis-progress-text').textContent = `${progress}%`;
                }
            }

            if (failedPages.length > 0 && !this.isSearchCancelled) {
                const resultsChamber = document.getElementById(CONFIG.IDS.RESULTS_MODAL);
                if (resultsChamber) {
                    resultsChamber.querySelector('.anubis-status-text').textContent = '로딩 실패 페이지 재시도 중...';
                }

                const uniqueFailedPages = [...new Set(failedPages)].sort((a, b) => a - b);
                const pagesToRetrySet = new Set();
                uniqueFailedPages.forEach(page => {
                    pagesToRetrySet.add(page);
                    pagesToRetrySet.add(page + 1);
                    pagesToRetrySet.add(page + 2);
                });

                const pagesToRetry = [...pagesToRetrySet].sort((a, b) => a - b);
                failedPages = [];

                for (let i = 0; i < pagesToRetry.length; i += CONFIG.CONSTANTS.SEARCH_CHUNK_SIZE) {
                    if (this.isSearchCancelled) break;

                    const chunk = pagesToRetry.slice(i, i + CONFIG.CONSTANTS.SEARCH_CHUNK_SIZE);
                    const promises = chunk.map(page => this.fetchPageHtml(params.galleryType, params.galleryId, page));
                    const results = await Promise.all(promises);

                    results.forEach((html, index) => {
                        if (this.isSearchCancelled) return;
                        if (html) {
                            const postsFromPage = processor(html);
                            postsFromPage.forEach(post => {
                                if (!foundPostNos.has(post.postNo)) {
                                    foundPostNos.add(post.postNo);
                                    allPosts.push(post);
                                }
                            });
                        } else {
                            failedPages.push(chunk[index]);
                        }
                    });
                }
            }

            return { posts: allPosts, failedPages };
        },

        async searchPostsByPage(params) {
            const pagesToFetch = Array.from({ length: params.endPage - params.startPage + 1 }, (_, i) => params.startPage + i);
            const processor = (html) => DATA_PARSER.parseUserPostsFromHtml(html, params.userInfo.filterKey, params.galleryId, params.galleryType);
            const { posts: allPosts, failedPages } = await this._pageSearchEngine(params, pagesToFetch, processor);
            if (this.isSearchCancelled) return;
            allPosts.sort((a, b) => b.postNo - a.postNo);
            const failureText = this.formatFailedPages(failedPages);
            const statusText = `총 ${allPosts.length}개의 글을 찾았습니다. (${params.startPage}~${params.endPage}p)${failureText}`;
            const finalModalHTML = TEMPLATES.resultsChamber({ ...params, posts: allPosts, isLoading: false, statusText });
            DOM_UTILS.showChamber(CONFIG.IDS.RESULTS_MODAL, finalModalHTML);
        },

        async searchPostsByDate(params) {
            const dateStartPage = params.dateStartPage || 1;
            const pagesToFetch = Array.from({ length: CONFIG.CONSTANTS.MAX_DATE_SEARCH_PAGES }, (_, i) => dateStartPage + i);
            const processor = (html) => DATA_PARSER.parseUserPostsFromHtml(html, params.userInfo.filterKey, params.galleryId, params.galleryType);
            const { posts: allPosts, failedPages } = await this._pageSearchEngine(params, pagesToFetch, processor, new Date(params.startDate.replace(/-/g, '/')).getTime());
            if (this.isSearchCancelled) return;

            const startTimestamp = new Date(params.startDate.replace(/-/g, '/')).getTime();
            const end = new Date(params.endDate.replace(/-/g, '/'));
            end.setHours(23, 59, 59, 999);
            const endTimestamp = end.getTime();
            const filteredPosts = allPosts.filter(p => {
                const postTime = new Date(p.timestamp).getTime();
                return postTime >= startTimestamp && postTime <= endTimestamp;
            });

            filteredPosts.sort((a, b) => b.postNo - a.postNo);
            const failureText = this.formatFailedPages(failedPages);
            const statusText = `총 ${filteredPosts.length}개의 글을 찾았습니다.${failureText}`;
            const finalModalHTML = TEMPLATES.resultsChamber({ ...params, posts: filteredPosts, isLoading: false, statusText });
            DOM_UTILS.showChamber(CONFIG.IDS.RESULTS_MODAL, finalModalHTML);
        },

        async filterPosts(params) {
            const isDateSearch = !!params.startDate;
            const dateStartPage = params.dateStartPage || 1;
            const pagesToFetch = isDateSearch
                ? Array.from({ length: CONFIG.CONSTANTS.MAX_DATE_SEARCH_PAGES }, (_, i) => dateStartPage + i)
                : Array.from({ length: params.endPage - params.startPage + 1 }, (_, i) => params.startPage + i);

            const processor = (html) => {
                return DATA_PARSER.parseAllPostsFromHtml(html, params.galleryId, params.galleryType)
                    .filter(post => {
                        const isGallchu = ['icon_recomtxt', 'icon_recomimg', 'icon_recomovie'].includes(post.dataType);
                        return post.dataType !== 'icon_notice' &&
                               !(params.excludeGallchu && isGallchu) &&
                               post.reco >= params.minReco &&
                               post.commentCount >= params.minComments &&
                               post.views >= params.minViews;
                    });
            };
            const { posts: allPosts, failedPages } = await this._pageSearchEngine(params, pagesToFetch, processor, params.startDate ? new Date(params.startDate.replace(/-/g, '/')).getTime() : null);
            if (this.isSearchCancelled) return;

            let finalPosts = allPosts;
            if (params.startDate) {
                const startTimestamp = new Date(params.startDate.replace(/-/g, '/')).getTime();
                const end = new Date(params.endDate.replace(/-/g, '/'));
                end.setHours(23, 59, 59, 999);
                const endTimestamp = end.getTime();
                finalPosts = allPosts.filter(p => {
                    const postTime = new Date(p.timestamp).getTime();
                    return postTime >= startTimestamp && postTime <= endTimestamp;
                });
            }

            finalPosts.sort((a, b) => b.postNo - a.postNo);
            const failureText = this.formatFailedPages(failedPages);
            const statusText = `총 ${finalPosts.length}개의 글을 찾았습니다.${failureText}`;
            const finalModalHTML = TEMPLATES.resultsChamber({ ...params, posts: finalPosts, isLoading: false, statusText });
            DOM_UTILS.showChamber(CONFIG.IDS.RESULTS_MODAL, finalModalHTML);
        },

        fetchPageHtml(galleryType, galleryId, pageNum) {
            const path = galleryType === 'board' ? 'board' : `${galleryType}/board`;
            const url = `https://gall.dcinside.com/${path}/lists/?id=${galleryId}&page=${pageNum}`;
            return new Promise(resolve => {
                GM_xmlhttpRequest({
                    method: 'GET', url, timeout: 10000,
                    onload: res => res.status === 200 ? resolve(res.responseText) : resolve(null),
                    onerror: () => resolve(null), ontimeout: () => resolve(null)
                });
            });
        },

        async openSettingsChamber() {
            this.isSettingsDirty = false;
            await this.loadSettings();
            const chamberHTML = TEMPLATES.settingsChamber();
            const onCloseCallback = () => this.isSettingsDirty ? confirm('설정이 저장되지 않았습니다.\n계속하시겠습니까?') : true;
            const chamber = DOM_UTILS.showChamber(CONFIG.IDS.SETTINGS_MODAL, chamberHTML, onCloseCallback);
            await this._bindSettingsChamberEvents(chamber);
        },

        async _bindSettingsChamberEvents(chamber) {
            const saveStatus = chamber.querySelector('#anubis-settings-save-status');
            const markDirty = () => { this.isSettingsDirty = true; saveStatus.style.display = 'none'; };
            const updateSliderTrack = (slider) => {
                const min = slider.min || 1, max = slider.max || 1, value = slider.value || 1;
                slider.style.setProperty('--slider-progress', `${((value - min) / (max - min)) * 100}%`);
            };

            const endPageSlider = chamber.querySelector('#anubis-default-end-page');
            const endPageValue = chamber.querySelector('#anubis-default-end-page-value');
            const dateRangeSlider = chamber.querySelector('#anubis-default-date-range');
            const dateRangeValue = chamber.querySelector('#anubis-default-date-range-value');

            const updateUI = async () => {
                await this.loadSettings();
                chamber.querySelector('#anubis-default-search-type').value = this.settings.defaultSearchType;
                endPageSlider.value = this.settings.defaultEndPage;
                endPageValue.textContent = `${this.settings.defaultEndPage}p`;
                updateSliderTrack(endPageSlider);
                dateRangeSlider.value = this.settings.defaultDateRange;
                dateRangeValue.textContent = `${this.settings.defaultDateRange}일`;
                updateSliderTrack(dateRangeSlider);
            };

            chamber.querySelectorAll('select, input[type="range"]').forEach(el => el.addEventListener('input', markDirty));

            endPageSlider.addEventListener('input', () => {
                endPageValue.textContent = `${endPageSlider.value}p`;
                updateSliderTrack(endPageSlider);
                markDirty();
            });
            dateRangeSlider.addEventListener('input', () => {
                dateRangeValue.textContent = `${dateRangeSlider.value}일`;
                updateSliderTrack(dateRangeSlider);
                markDirty();
            });

            chamber.querySelector('#anubis-settings-save').addEventListener('click', async () => {
                const S = CONFIG.STORAGE_KEYS;
                await GM_setValue(S.DEFAULT_SEARCH_TYPE, chamber.querySelector('#anubis-default-search-type').value);
                await GM_setValue(S.DEFAULT_END_PAGE, parseInt(endPageSlider.value, 10));
                await GM_setValue(S.DEFAULT_DATE_RANGE, parseInt(dateRangeSlider.value, 10));
                this.isSettingsDirty = false;
                saveStatus.textContent = '저장됨';
                saveStatus.style.display = 'inline';
            });

            chamber.querySelector('#anubis-reset-settings').addEventListener('click', async () => {
                if (confirm("ANUBIS 설정을 기본값으로 되돌리시겠습니까?")) {
                    const keysToDelete = (await GM_listValues()).filter(key => key.startsWith('anubis_'));
                    await Promise.all(keysToDelete.map(key => GM_deleteValue(key)));
                    await updateUI();
                    markDirty();
                }
            });

            await updateUI();
        },

        _getGalleryInfoFromSelector(chamber, idPrefix) {
            const gallerySelect = chamber.querySelector(`#${idPrefix}-gallery-select`);
            const isDirectInput = gallerySelect.value === '__direct_input__';
            if (isDirectInput) {
                const id = chamber.querySelector(`#${idPrefix}-gallery-direct-input`).value.trim();
                const type = chamber.querySelector(`input[name="${idPrefix}-gallery-type"]:checked`).value;
                return { galleryId: id, galleryType: type, galleryDisplayName: `갤러리 (ID: ${id})`, isDirectInput: true };
            }
            const selectedOption = gallerySelect.options[gallerySelect.selectedIndex];
            return { galleryId: selectedOption.value, galleryType: selectedOption.dataset.type, galleryDisplayName: `${selectedOption.textContent.trim()} 갤러리`, isDirectInput: false };
        },

        async _bindGallerySelector(chamber, idPrefix) {
            const gallerySelect = chamber.querySelector(`#${idPrefix}-gallery-select`);
            const directInputContainer = chamber.querySelector(`#${idPrefix}-gallery-direct-input-container`);
            const recentGalleries = DATA_PARSER.getRecentGalleries();
            const galleryMap = new Map(recentGalleries.map(g => [g.id, g]));
            const urlParams = new URLSearchParams(window.location.search);
            const currentGalleryId = urlParams.get('id');
            if (currentGalleryId && !galleryMap.has(currentGalleryId)) {
                const name = DATA_PARSER.parseGalleryNameFromHtml(document.documentElement.outerHTML) || currentGalleryId;
                const type = window.location.pathname.split('/').filter(Boolean)[0];
                galleryMap.set(currentGalleryId, { id: currentGalleryId, name, type });
            }
            gallerySelect.innerHTML = '<option value="__direct_input__">직접 입력</option>' + [...galleryMap.values()].map(g => `<option value="${g.id}" data-type="${g.type}">${this.escapeHtml(g.name)}</option>`).join('');
            gallerySelect.value = galleryMap.has(currentGalleryId) ? currentGalleryId : '__direct_input__';
            const toggleDirectInput = () => directInputContainer.style.display = gallerySelect.value === '__direct_input__' ? 'block' : 'none';
            gallerySelect.addEventListener('change', toggleDirectInput);
            toggleDirectInput();
        },

        escapeHtml(text) {
            if (typeof text !== 'string') return text;
            const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
            return text.replace(/[&<>"']/g, m => map[m]);
        },

        formatFailedPages(pages) {
            if (!pages || pages.length === 0) return '';
            const uniquePages = [...new Set(pages)].sort((a, b) => a - b);
            if (uniquePages.length === 0) return '';
            const ranges = [];
            let start = uniquePages[0];
            let end = uniquePages[0];
            for (let i = 1; i < uniquePages.length; i++) {
                if (uniquePages[i] === end + 1) {
                    end = uniquePages[i];
                } else {
                    ranges.push(start === end ? `${start}p` : `${start}~${end}p`);
                    start = uniquePages[i];
                    end = uniquePages[i];
                }
            }
            ranges.push(start === end ? `${start}p` : `${start}~${end}p`);
            return ` (실패: ${ranges.join(', ')})`;
        },
    };

    CORE_LOGIC.initialize();

})();