HORUS

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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();

})();