HORUS

디시인사이드 말머리 차단 기능

// ==UserScript==
// @name         HORUS
// @namespace    HORUS-DEADlock
// @version      1.0.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/LypOzKK.png
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      dcinside.com
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        mainModalId: 'horus-blocker-modal',
        mgmtModalId: 'horus-management-modal',
        settingsKey: 'horus_gallery_settings',
        darkThemeClass: 'horus-dark-theme',
    };

    const Utils = {
        getSettings: () => JSON.parse(GM_getValue(CONFIG.settingsKey, '{}')),
        saveSettings: (settings) => GM_setValue(CONFIG.settingsKey, JSON.stringify(settings)),
        getCurrentGalleryId: () => {
            const params = new URLSearchParams(window.location.search);
            return params.get('id');
        },
        getCurrentGalleryName: () => {
            const selectors = ['.gall_name_inner .gall_name a', '.page_head h2 a', '.gall_name .text'];
            for (const selector of selectors) {
                const el = document.querySelector(selector);
                if (el) {
                    const clone = el.cloneNode(true);
                    clone.querySelectorAll('em, span').forEach(tag => tag.remove());
                    return clone.textContent.trim();
                }
            }
            return '알 수 없는 갤러리';
        },
        applyTheme: () => document.body.classList.toggle(CONFIG.darkThemeClass, !!document.getElementById('css-darkmode')),
        fetchAvailableFlairs: (galleryId) => {
            return new Promise((resolve, reject) => {
                const pathname = window.location.pathname;
                const boardIndex = pathname.indexOf('/board/');
                let galleryPath;

                if (boardIndex > 0) {
                    const prefix = pathname.substring(1, boardIndex);
                    galleryPath = `${prefix}/board`;
                } else {
                    galleryPath = 'board';
                }

                const listUrl = `https://gall.dcinside.com/${galleryPath}/lists/?id=${galleryId}`;

                GM_xmlhttpRequest({
                    method: 'GET',
                    url: listUrl,
                    onload: function(response) {
                        if (response.status >= 200 && response.status < 300) {
                            const parser = new DOMParser();
                            const doc = parser.parseFromString(response.responseText, 'text/html');
                            const flairElements = doc.querySelectorAll('.list_array_option .inner ul li a');
                            const flairs = new Set([...flairElements].map(el => el.innerText.trim()).filter(Boolean));
                            resolve(flairs);
                        } else {
                            reject(new Error(`Failed to fetch flairs. Status: ${response.status}, URL: ${listUrl}`));
                        }
                    },
                    onerror: function(error) {
                        reject(new Error(`Network error while fetching flairs. URL: ${listUrl}`));
                    }
                });
            });
        }
    };

    const UI = {
        createModal: function({ id, title, bodyHTML, footerHTML }) {
            const existingModal = document.getElementById(id);
            if (existingModal) existingModal.remove();

            const modalWrapper = document.createElement('div');
            modalWrapper.innerHTML = `
                <div id="${id}" class="${id}">
                    <div class="horus-modal-header">
                        <span>${title}</span>
                        <button class="horus-modal-close-btn">&times;</button>
                    </div>
                    <div class="horus-modal-body">${bodyHTML}</div>
                    ${footerHTML ? `<div class="horus-modal-footer">${footerHTML}</div>` : ''}
                </div>
            `;
            const modalElement = modalWrapper.firstElementChild;
            document.body.appendChild(modalElement);
            Utils.applyTheme();
            return modalElement;
        },

        openGalleryManagementModal: function() {
            let idsToDelete = new Set();
            
            const renderList = () => {
                const settings = Utils.getSettings();
                const galleryIds = Object.keys(settings);
                const modal = document.getElementById(CONFIG.mgmtModalId);
                if (!modal) return;
                
                const description = modal.querySelector('.horus-modal-description');
                const galleryListDiv = modal.querySelector('.horus-gallery-list');
                const footer = modal.querySelector('.horus-modal-footer');

                description.textContent = galleryIds.length > 0 ? '설정을 초기화할 갤러리를 선택해 주세요' : '말머리 차단이 설정된 갤러리가 없습니다';

                if (galleryIds.length === 0) {
                    galleryListDiv.innerHTML = '';
                    if (footer) footer.style.display = 'none';
                    return;
                }

                if (footer) footer.style.display = 'flex';
                galleryListDiv.innerHTML = galleryIds.map(id => `<div class="horus-gallery-list-item ${idsToDelete.has(id) ? 'marked-for-deletion' : ''}" data-id="${id}"><span>${settings[id].name || '이름 정보 없음'}</span></div>`).join('');
                galleryListDiv.querySelectorAll('.horus-gallery-list-item').forEach(item => {
                    item.addEventListener('click', () => {
                        const id = item.dataset.id;
                        idsToDelete.has(id) ? idsToDelete.delete(id) : idsToDelete.add(id);
                        item.classList.toggle('marked-for-deletion');
                        modal.querySelector('.horus-save-status').style.display = 'none';
                    });
                });
            };

            const bodyHTML = `<p class="horus-modal-description"></p><div class="horus-gallery-list"></div>`;
            const footerHTML = `<button class="horus-btn horus-remove-all-btn">모두 선택</button><div class="horus-button-group"><span class="horus-save-status"></span><button class="horus-btn horus-confirm-btn">저장</button></div>`;
            const modal = this.createModal({ id: CONFIG.mgmtModalId, title: '갤러리 설정 관리', bodyHTML, footerHTML });
            
            modal.querySelector('.horus-modal-close-btn').addEventListener('click', () => { if (idsToDelete.size > 0 && !confirm('저장되지 않은 변경사항이 있습니다\n계속하시겠습니까?')) return; modal.remove(); });
            modal.querySelector('.horus-remove-all-btn').addEventListener('click', () => { const allIds = Object.keys(Utils.getSettings()); allIds.length === idsToDelete.size ? idsToDelete.clear() : allIds.forEach(id => idsToDelete.add(id)); renderList(); });
            modal.querySelector('.horus-confirm-btn').addEventListener('click', () => {
                if (idsToDelete.size > 0) {
                    const currentSettings = Utils.getSettings();
                    idsToDelete.forEach(id => delete currentSettings[id]);
                    Utils.saveSettings(currentSettings);
                    idsToDelete.clear();
                    Core.run();
                    renderList();
                }
                modal.querySelector('.horus-save-status').textContent = '저장됨';
                modal.querySelector('.horus-save-status').style.display = 'inline';
            });
            renderList();
        },

        openSettingsModal: async function() {
            const galleryId = Utils.getCurrentGalleryId();
            if (!galleryId) {
                alert('갤러리 ID를 가져올 수 없습니다');
                return;
            }

            const loadingModal = this.createModal({
                id: CONFIG.mainModalId,
                title: 'HORUS 설정',
                bodyHTML: '<p style="padding: 40px; text-align: center;">말머리 목록을 불러오는 중입니다...</p>',
                footerHTML: ''
            });

            try {
                const availableFlairs = await Utils.fetchAvailableFlairs(galleryId);
                loadingModal.remove();

                const settings = Utils.getSettings();
                const blockedFlairs = new Set(settings[galleryId]?.blocked || []);

                const flairItemsHTML = [...availableFlairs].map(flair => `
                    <label class="horus-list-item">
                        <input type="checkbox" value="${flair}" ${!blockedFlairs.has(flair) ? 'checked' : ''}>
                        ${flair}
                    </label>
                `).join('');

                const labelText = availableFlairs.size > 0 ? '페이지에 노출할 말머리를 선택해 주세요' : '말머리가 존재하지 않습니다';

                const bodyHTML = `
                    <button class="horus-btn horus-manage-btn horus-manage-btn-top">갤러리 설정 관리</button>
                    <div class="horus-form-group">
                        <label class="horus-form-label normal-weight">${labelText}</label>
                        <div class="horus-flair-list">
                            ${flairItemsHTML}
                        </div>
                    </div>
                `;
                const footerHTML = `
                    <button class="horus-btn horus-reset-btn">기본값</button>
                    <div class="horus-button-group">
                        <span class="horus-save-status"></span>
                        <button class="horus-btn horus-confirm-btn">저장</button>
                    </div>
                `;

                const modal = this.createModal({ id: CONFIG.mainModalId, title: 'HORUS 설정', bodyHTML, footerHTML });
                let isDirty = false;
                const saveStatus = modal.querySelector('.horus-save-status');
                const markDirty = () => { isDirty = true; saveStatus.style.display = 'none'; };

                modal.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.addEventListener('change', markDirty));
                modal.querySelector('.horus-modal-close-btn').addEventListener('click', () => { if (isDirty && !confirm('저장되지 않은 변경사항이 있습니다\n계속하시겠습니까?')) return; modal.remove(); });
                modal.querySelector('.horus-manage-btn').addEventListener('click', () => { modal.remove(); this.openGalleryManagementModal(); });
                modal.querySelector('.horus-confirm-btn').addEventListener('click', () => {
                    const flairsToBlock = [...modal.querySelectorAll('input:not(:checked)')].map(input => input.value);
                    const currentSettings = Utils.getSettings();
                    const galleryName = Utils.getCurrentGalleryName() || settings[galleryId]?.name || galleryId;
                    if (flairsToBlock.length > 0) {
                        currentSettings[galleryId] = { name: galleryName, blocked: flairsToBlock };
                    } else {
                        delete currentSettings[galleryId];
                    }
                    Utils.saveSettings(currentSettings);
                    isDirty = false;
                    saveStatus.textContent = '저장됨';
                    saveStatus.style.display = 'inline';
                    Core.run();
                });
                modal.querySelector('.horus-reset-btn').addEventListener('click', () => { modal.querySelectorAll('input[type="checkbox"]:not(:checked)').forEach(input => input.checked = true); markDirty(); });

            } catch (error) {
                console.error('[HORUS]', error);
                loadingModal.querySelector('.horus-modal-body').innerHTML = `<p style="padding: 40px; text-align: center;">말머리 목록을 불러오지 못했습니다.</p>`;
            }
        },
    };

    const Core = {
        filterPosts: function() {
            const settings = Utils.getSettings();
            const galleryId = Utils.getCurrentGalleryId();
            const blockedFlairs = settings[galleryId]?.blocked || [];
            if (blockedFlairs.length === 0) return;

            const posts = document.querySelectorAll('tr.ub-content');
            posts.forEach(post => {
                const subjectCell = post.querySelector('td.gall_subject');
                let isBlocked = false;
                if (subjectCell) {
                    const displayedFlair = subjectCell.textContent.trim();
                    isBlocked = blockedFlairs.some(fullBlockedFlair =>
                        fullBlockedFlair.length <= 3 ?
                        displayedFlair.startsWith(fullBlockedFlair) :
                        displayedFlair.startsWith(fullBlockedFlair.substring(0, 3))
                    );
                }
                post.style.display = isBlocked ? 'none' : '';
            });
        },

        filterFlairDropdown: function() {
            const settings = Utils.getSettings();
            const galleryId = Utils.getCurrentGalleryId();

            if (!galleryId || !settings[galleryId]) {
                const allFlairItems = document.querySelectorAll('.list_array_option .inner ul li');
                allFlairItems.forEach(item => {
                    item.style.display = '';
                });
                return;
            };

            const blockedFlairs = new Set(settings[galleryId]?.blocked || []);
            const flairItems = document.querySelectorAll('.list_array_option .inner ul li');
            
            flairItems.forEach(item => {
                const flairAnchor = item.querySelector('a');
                if (flairAnchor) {
                    const flairText = flairAnchor.textContent.trim();
                    item.style.display = blockedFlairs.has(flairText) ? 'none' : '';
                }
            });
        },

        handlePostView: function() {
            const settings = Utils.getSettings();
            const galleryId = Utils.getCurrentGalleryId();
            const blockedFlairs = new Set(settings[galleryId]?.blocked || []);
            if (blockedFlairs.size === 0) return;

            const flairElement = document.querySelector('.gall_writer.ub-writer');
            if (!flairElement) return;

            const postFlair = flairElement.dataset.headtext;
            if (postFlair && blockedFlairs.has(postFlair)) {
                document.body.style.overflow = 'hidden';
                const notice = document.createElement('div');
                notice.innerHTML = `
                    <div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 10002; display: flex; justify-content: center; align-items: center; color: white; font-size: 20px; font-family: 'Malgun Gothic', sans-serif;">
                        차단된 말머리의 게시글입니다. 2초 후 목록으로 돌아갑니다.
                    </div>
                `;
                document.body.appendChild(notice);
                setTimeout(() => {
                    history.back();
                }, 2000);
            }
        },
        
        run: function() {
            if (window.location.href.includes('/board/view')) {
                this.handlePostView();
            }
            this.filterPosts();
            this.filterFlairDropdown();
        },

        init: function() {
            GM_addStyle(`
                :root { --font-main: 'Malgun Gothic', sans-serif; --font-size-base: 13px; --font-size-header: 16px; --color-bg: #fff; --color-border: #ddd; --color-border-light: #e9e9e9; --color-border-dark: #ccc; --color-text-primary: #333; --color-text-secondary: #777; --color-text-inverse: #fff; --color-btn-confirm-bg: #333; --color-btn-confirm-text: #fff; --color-btn-cancel-bg: #fff; --color-btn-cancel-text: #333; --color-input-bg: #fff; --color-input-text: #555; --color-row-hover: #f5f5f5; }
                body.${CONFIG.darkThemeClass} { --color-bg: #1f1f1f; --color-border: #4a4b4f; --color-border-light: #444549; --color-border-dark: #555; --color-text-primary: #ccc; --color-text-secondary: #aaa; --color-btn-confirm-bg: #eee; --color-btn-confirm-text: #333; --color-btn-cancel-bg: #444; --color-btn-cancel-text: #e8e8e8; --color-input-bg: #1f1f1f; --color-input-text: #ccc; --color-row-hover: #2a2b2d; }
                .${CONFIG.mainModalId}, .${CONFIG.mgmtModalId} { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 10001; display: block; 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; }
                .${CONFIG.mainModalId} { width: 400px; } .${CONFIG.mgmtModalId} { width: 500px; }
                .horus-modal-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; }
                .horus-modal-close-btn { background: transparent; border: none; font-size: 24px; font-weight: 700; color: var(--color-text-secondary); cursor: pointer; transition: color .2s; }
                .horus-modal-close-btn:hover { color: var(--color-text-primary); }
                .horus-modal-body { padding: 20px; padding-bottom: 10px; }
                .horus-modal-footer { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; gap: 10px; }
                .horus-form-group { margin-bottom: 10px; }
                .horus-form-label { display: block; font-weight: 700; font-size: var(--font-size-base); color: var(--color-text-primary); margin-bottom: 8px; }
                .horus-form-label.normal-weight { font-weight: normal; }
                .horus-flair-list { display: block; max-height: 330px; overflow-y: auto; }
                .horus-list-item { display: flex; align-items: center; cursor: pointer; font-size: var(--font-size-base); padding: 8px 12px; border-bottom: 1px solid #f0f0f0; transition: background-color .2s; }
                body.${CONFIG.darkThemeClass} .horus-list-item { border-bottom-color: var(--color-border-light); }
                .horus-list-item:last-child { border-bottom: none; }
                .horus-list-item:hover { background-color: var(--color-row-hover); }
                .horus-list-item input[type="checkbox"] { margin-right: 10px; transform: scale(1.2); cursor: pointer; }
                .horus-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; transition: filter .2s, opacity .2s; }
                .horus-btn:hover:not(:disabled) { filter: brightness(.9); }
                .horus-confirm-btn { background-color: var(--color-btn-confirm-bg); color: var(--color-btn-confirm-text); border-color: var(--color-btn-confirm-bg); }
                body.${CONFIG.darkThemeClass} .horus-confirm-btn:hover:not(:disabled) { filter: brightness(.85); }
                .horus-reset-btn, .horus-manage-btn, .horus-remove-all-btn { background-color: var(--color-btn-cancel-bg); color: var(--color-btn-cancel-text); border-color: var(--color-border-dark); }
                body.${CONFIG.darkThemeClass} .horus-reset-btn:hover:not(:disabled), body.${CONFIG.darkThemeClass} .horus-manage-btn:hover:not(:disabled), body.${CONFIG.darkThemeClass} .horus-remove-all-btn:hover:not(:disabled) { filter: brightness(.8); }
                .horus-manage-btn-top { width: 100%; margin-bottom: 20px; }
                .horus-save-status { color: var(--color-text-secondary); font-size: var(--font-size-base); margin-right: auto; display: none; }
                .horus-button-group { display: flex; align-items: center; gap: 10px; }
                .horus-modal-description { font-size: var(--font-size-base); color: var(--color-text-secondary); margin-bottom: 15px; text-align: left; }
                .horus-gallery-list { display: flex; flex-wrap: wrap; align-content: flex-start; gap: 8px; max-height: 340px; overflow-y: auto; }
                .horus-gallery-list-item { display: inline-flex; align-items: center; background-color: var(--color-row-hover); border: 1px solid var(--color-border-light); border-radius: 16px; padding: 5px 12px; font-size: var(--font-size-base); color: var(--color-text-primary); cursor: pointer; transition: background-color .2s, border-color .2s, opacity .2s; }
                .horus-gallery-list-item:hover { background-color: var(--color-border-light); }
                .horus-gallery-list-item.marked-for-deletion { opacity: 0.6; text-decoration: line-through; }
            `);

            GM_registerMenuCommand('말머리 설정', UI.openSettingsModal.bind(UI));

            const themeObserver = new MutationObserver(Utils.applyTheme);
            themeObserver.observe(document.head, { childList: true });

            const mainObserver = new MutationObserver((mutations) => {
                const listContainer = document.querySelector('.list_wrap, .gall_list_wrap');
                if (listContainer && !listContainer.hasAttribute('data-horus-observed')) {
                    listContainer.setAttribute('data-horus-observed', 'true');
                    this.filterPosts();
                    const postListObserver = new MutationObserver(() => this.filterPosts());
                    postListObserver.observe(listContainer, { childList: true, subtree: true });
                }
            });

            mainObserver.observe(document.body, { childList: true, subtree: true });
            
            window.addEventListener('load', () => {
                Utils.applyTheme();
                this.run();
            });
        }
    };

    Core.init();

})();