Gelbooru Tag Copyer

复制 Gelbooru 的 tags,支持自定义选择 tag 类型 / Quickly copy tags from gelbooru's post so you can use thoes tags to generate AI images.

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

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que 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.

Tendrás que instalar una extensión como Tampermonkey antes de poder 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)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

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

// ==UserScript==
// @name         Gelbooru Tag Copyer
// @namespace    https://greasyfork.org/zh-CN/scripts/439308
// @version      2.5
// @description  复制 Gelbooru 的 tags,支持自定义选择 tag 类型 / Quickly copy tags from gelbooru's post so you can use thoes tags to generate AI images.
// @author       3989364
// @include      *://gelbooru.com/index.php*
// @icon         https://gelbooru.com/favicon.ico
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";
    const tagListEle = document.querySelector("#tag-list");
    if (!tagListEle) return;

    // ==========================================
    // 用户配置区 (User Configuration)
    // ==========================================
    const DEFAULT_CHECKED_TAGS = ["general", "copyright", "character"];
    const DEFAULT_QUALITY_TAG = ''; // 例: `((masterpiece)), (((best quality)))`;
    const ARTIST_PREFIX = "@"; // <-- 全局变量: 画师标签前缀,可在此自由修改
    // ==========================================

    const attriTags = ["hair", "eyes", "dress", "sleeves", "bow"];
    const EXCLUDED_TAGS = ["censor", "out-of-frame", "speech bubble"];

    const MODE_STORAGE_KEY = "GelbooruTagCopyer_Mode";
    const CHECKED_STATE_KEY = "GelbooruTagCopyer_CheckedState";
    const FETCH_URL = "https://gist.githubusercontent.com/magicFeirl/f5698110526037dc4dc9026b5af358b5/raw/542c2654470864c3cf24da6815fdfe0c8f334343/tags.json";

    const tag_type_map = {
        1: "other",
        2: "scenery",
        3: "appearance",
        4: "pose",
        5: "clothing"
    };

    const customFallbackTags = {
        "clothing": ["wear", "uniform", "costume", "dress", "bikini", "swimsuit", "lingerie", "underwear", "panties", "bra", "shirt", "pants", "shorts", "skirt", "jacket", "coat", "sweater", "hoodie", "vest", "gloves", "mittens", "shoes", "boots", "sneakers", "socks", "stockings", "pantyhose", "leggings", "hat", "cap", "helmet", "glasses", "eyewear", "mask", "necklace", "earrings", "jewelry", "ribbon", "tie", "scarf", "belt", "bag", "backpack", "armor", "bodysuit", "leotard", "apron", "kimono", "yukata"],
        "pose": ["standing", "sitting", "lying", "kneeling", "squatting", "walking", "running", "jumping", "flying", "swimming", "sleeping", "looking", "view", "leaning", "reaching", "holding", "carrying", "hugging", "kissing", "arms up", "arms behind", "legs crossed", "legs apart", "selfie", "peace sign", "stretching", "crying", "laughing", "smiling", "blush", "expression", "looking at viewer", "looking back", "from behind", "from below", "from above", "side view", "back view"],
        "scenery": ["indoors", "outdoors", "background", "sky", "cloud", "sun", "moon", "star", "water", "sea", "ocean", "river", "lake", "pool", "beach", "mountain", "forest", "tree", "flower", "grass", "plant", "nature", "city", "town", "village", "building", "house", "room", "bed", "couch", "chair", "table", "window", "door", "floor", "wall", "ceiling", "road", "street", "ruins", "scenery", "landscape", "night", "day", "sunset", "sunrise", "rain", "snow"],
        "appearance": ["1girl", "1boy", "2girls", "2boys", "hair", "eyes", "skin", "breasts", "chest", "nipples", "pussy", "penis", "tail", "wings", "horns", "ears", "animal", "fur", "scales", "muscle", "fat", "pregnant", "tall", "short", "body", "face", "grin", "smile", "blonde", "brunette", "redhead", "silver", "grey", "blue", "green", "heterochromia", "ahoge", "twintails", "ponytail", "braid", "buns"]
    };

    const BY_TYPE_CATEGORIES = ["appearance", "clothing", "pose", "scenery", "other"];
    const BY_TYPE_DEFAULT_CHECKED = ["pose"];

    // SVG 图标定义
    const SVG_COPY = `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
    const SVG_CHECK = `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;

    /**
     * @param tagListEle
     * @returns [String, ...]
     */
    function parseTags(tagListEle) {
        const tags = Object.create(null);

        tagListEle
            .querySelectorAll('li[class^="tag-type"]')
            .forEach((tagItem) => {
            const tagEle = tagItem.querySelector(".sm-hidden + a");
            const tag = tagEle.textContent;
            const tagCount = parseInt(tagEle.nextElementSibling.textContent) || 0;
            const tagType = tagItem.className.replace("tag-type-", "");

            if (!tags[tagType]) {
                tags[tagType] = [];
            }
            tags[tagType].push({ tag, tagCount });
        });

        if ("general" in tags) {
            tags["general"].sort((a, b) => b.tagCount - a.tagCount);
        }

        for (const key in tags) {
            tags[key] = tags[key].map((item) => item.tag);
        }

        tags["attrTag"] = [];
        return tags;
    }

    // 【修改点】使用紧凑布局和 SVG 按钮
    function createTagCheckbox(tagType, checked = false, labelText = "") {
        const el = document.createElement("div");

        el.style.marginBottom = "0.25rem";
        el.style.display = "flex";
        el.style.alignItems = "center";
        el.style.width = "100%";

        el.innerHTML = `
        <label style="display:flex; align-items:center; cursor:pointer; user-select:none; margin-right: 6px;">
            <input type="checkbox" name="${tagType}" ${checked ? "checked" : ""}>
            <span style="margin-left:4px;">${labelText || tagType}</span>
        </label>
        <button class="mini-copy-btn" style="display:none; cursor:pointer; padding:2px; background:none; border:none; color:inherit; opacity:0.6; align-items:center; justify-content:center; transform:translateY(1px);" title="Copy this category only">
            ${SVG_COPY}
        </button>
        `;

        const copyBtn = el.querySelector(".mini-copy-btn");

        // 鼠标悬浮整行显示按钮
        el.addEventListener("mouseenter", () => {
            copyBtn.style.display = "flex";
        });
        el.addEventListener("mouseleave", () => {
            copyBtn.style.display = "none";
        });

        // 悬浮按钮自身加深颜色
        copyBtn.addEventListener("mouseenter", () => copyBtn.style.opacity = "1");
        copyBtn.addEventListener("mouseleave", () => copyBtn.style.opacity = "0.6");

        // 独立复制逻辑
        copyBtn.addEventListener("click", async (e) => {
            e.preventDefault();
            e.stopPropagation();

            const mode = document.querySelector("#gelbooru-copy-mode").value;
            let sourceTags = [];

            if (mode === "Default") {
                sourceTags = tagsObj[tagType] || [];
            } else {
                const categorized = await getCategorizedTags();
                sourceTags = categorized[tagType] || tagsObj[tagType] || [];
            }

            if (tagType === "artist" && sourceTags.length > 0) {
                sourceTags = sourceTags.map(tag => ARTIST_PREFIX + tag);
            }

            const tagsString = sourceTags.join(", ");

            if (tagsString) {
                navigator.clipboard.writeText(tagsString).then(() => {
                    // 成功后变为绿色的对号
                    copyBtn.innerHTML = SVG_CHECK;
                    copyBtn.style.color = "var(--green, #28a745)";
                    copyBtn.style.opacity = "1";

                    setTimeout(() => {
                        copyBtn.innerHTML = SVG_COPY;
                        copyBtn.style.color = "inherit";
                        copyBtn.style.opacity = "0.6";
                    }, 1200);
                    window.notice && window.notice(`Copied ${tagType} tags.`);
                }).catch(() => {
                    prompt(`Copied ${tagType} tags:`, tagsString);
                });
            } else {
                window.notice && window.notice(`No tags found for ${tagType}.`);
            }
        });

        return [el, el.querySelector("input")];
    }

    function getSavedCheckedStates() {
        try {
            const saved = localStorage.getItem(CHECKED_STATE_KEY);
            return saved ? JSON.parse(saved) : {};
        } catch (e) {
            return {};
        }
    }

    function saveCheckedStates(mode, checkedItems) {
        const states = getSavedCheckedStates();
        states[mode] = checkedItems;
        localStorage.setItem(CHECKED_STATE_KEY, JSON.stringify(states));
    }

    // ==========================================
    // 1. 初始化并处理 Tags
    // ==========================================
    const tagsObj = parseTags(tagListEle);

    if ("character" in tagsObj) {
        tagsObj["character"] = tagsObj["character"].map(
            (character) => `${character}`
        );
    }

    if ("general" in tagsObj) {
        tagsObj["attrTag"] = tagsObj["general"].filter((tag) =>
                                                       attriTags.some((attrTag) => tag.includes(attrTag))
                                                      );

        tagsObj["general"] = tagsObj["general"]
            .filter((name) => !EXCLUDED_TAGS.some((exTags) => name.includes(exTags)))
            .filter((tag) => !attriTags.some((attrTag) => tag.includes(attrTag)));
    }

    for (const tagType in tagsObj) {
        tagsObj[tagType] = tagsObj[tagType].map(item => item.replaceAll('(', '\\(').replaceAll(')', '\\)'));
    }

    // ==========================================
    // 2. 获取并分类 "By Type" 数据逻辑
    // ==========================================
    async function getCategorizedTags() {
        let tagsTypeDictStr = localStorage.getItem('TAGS_TYPE');
        if (!tagsTypeDictStr) {
            try {
                const response = await fetch(FETCH_URL);
                const data = await response.json();
                tagsTypeDictStr = JSON.stringify(data);
                localStorage.setItem('TAGS_TYPE', tagsTypeDictStr);
            } catch (e) {
                console.error("Gelbooru Tag Copyer: Failed to fetch TAGS_TYPE", e);
                tagsTypeDictStr = "{}";
            }
        }

        let typeMapping = {};
        try {
            typeMapping = JSON.parse(tagsTypeDictStr || "{}");
        } catch (e) {
            console.error("Gelbooru Tag Copyer: Failed to parse TAGS_TYPE JSON", e);
        }

        const generalTags = tagsObj["general"] || [];
        const uniqueTags = [...new Set(generalTags)];

        const categorized = {
            "appearance": [],
            "clothing": [],
            "pose": [],
            "scenery": [],
            "other": []
        };

        uniqueTags.forEach(tag => {
            const unescapedTag = tag.replaceAll('\\(', '(').replaceAll('\\)', ')');
            const typeId = typeMapping[unescapedTag];
            let mappedCategory = tag_type_map[typeId];

            if (!mappedCategory || mappedCategory === "other") {
                let foundInCustom = false;
                for (const cat in customFallbackTags) {
                    if (customFallbackTags[cat].some(keyword => unescapedTag.includes(keyword))) {
                        mappedCategory = cat;
                        foundInCustom = true;
                        break;
                    }
                }
                if (!foundInCustom) {
                    mappedCategory = "other";
                }
            }

            if (categorized[mappedCategory]) {
                categorized[mappedCategory].push(tag);
            }
        });

        return categorized;
    }

    // ==========================================
    // 3. 构建 UI
    // ==========================================
    const uiContainer = document.createElement("li");
    uiContainer.style.marginBottom = "10px";
    uiContainer.style.padding = "5px";
    uiContainer.style.borderRadius = "4px";
    uiContainer.style.backgroundColor = "var(--bg-color, transparent)";

    const topControlRow = document.createElement("div");
    topControlRow.style.display = "flex";
    topControlRow.style.alignItems = "center";
    topControlRow.style.marginBottom = "8px";

    const modeLabel = document.createElement("label");
    modeLabel.innerText = "Mode: ";
    modeLabel.style.fontWeight = "bold";
    modeLabel.style.marginRight = "6px";
    modeLabel.htmlFor = "gelbooru-copy-mode";

    const modeSelect = document.createElement("select");
    modeSelect.id = "gelbooru-copy-mode";
    modeSelect.innerHTML = `
        <option value="Default">Default</option>
        <option value="By Type">By Type</option>
    `;
    modeSelect.style.padding = "2px 4px";

    const savedMode = localStorage.getItem(MODE_STORAGE_KEY) || "Default";
    modeSelect.value = savedMode;

    topControlRow.appendChild(modeLabel);
    topControlRow.appendChild(modeSelect);

    const tagCheckboxContainer = document.createElement("div");

    tagCheckboxContainer.addEventListener('change', (e) => {
        if (e.target && e.target.type === 'checkbox') {
            const currentMode = modeSelect.value;
            const checkedItems = currentCheckboxes
            .filter(item => item.checked)
            .map(item => item.name);
            saveCheckedStates(currentMode, checkedItems);
        }
    });

    const copyBtn = document.createElement("button");
    copyBtn.innerText = "Copy";
    copyBtn.style.marginTop = "8px";
    copyBtn.style.cursor = "pointer";

    uiContainer.appendChild(topControlRow);
    uiContainer.appendChild(tagCheckboxContainer);
    uiContainer.appendChild(copyBtn);

    tagListEle.insertBefore(uiContainer, tagListEle.firstChild);

    let currentCheckboxes = [];

    // ==========================================
    // 4. 动态渲染逻辑
    // ==========================================
    async function renderCheckboxes(mode) {
        currentCheckboxes = [];

        const savedStates = getSavedCheckedStates();
        const initiallyChecked = savedStates[mode] || (mode === 'Default' ? DEFAULT_CHECKED_TAGS : BY_TYPE_DEFAULT_CHECKED);

        if (mode === 'Default') {
            tagCheckboxContainer.innerHTML = '';
            for (const tagType in tagsObj) {
                if (tagType === "attrTag") continue;

                const isChecked = initiallyChecked.includes(tagType);
                const [wrapper, ckbox] = createTagCheckbox(tagType, isChecked);
                tagCheckboxContainer.appendChild(wrapper);
                currentCheckboxes.push(ckbox);
            }
        } else if (mode === 'By Type') {
            tagCheckboxContainer.innerHTML = '<span style="font-size:12px;color:gray;">Loading categories...</span>';
            copyBtn.disabled = true;

            const categorized = await getCategorizedTags();
            tagCheckboxContainer.innerHTML = '';

            BY_TYPE_CATEGORIES.forEach(cat => {
                const tagCount = categorized[cat].length;
                if (tagCount > 0) {
                    const labelText = `${cat} <span style="font-size: 0.9em; opacity: 0.7;">(${tagCount})</span>`;
                    const isChecked = initiallyChecked.includes(cat);
                    const [wrapper, ckbox] = createTagCheckbox(cat, isChecked, labelText);
                    tagCheckboxContainer.appendChild(wrapper);
                    currentCheckboxes.push(ckbox);
                }
            });

            for (const tagType in tagsObj) {
                if (tagType === "general" || tagType === "attrTag") continue;

                const isChecked = initiallyChecked.includes(tagType);
                const [wrapper, ckbox] = createTagCheckbox(tagType, isChecked);
                tagCheckboxContainer.appendChild(wrapper);
                currentCheckboxes.push(ckbox);
            }

            copyBtn.disabled = false;
        }
    }

    modeSelect.addEventListener("change", (e) => {
        const newMode = e.target.value;
        localStorage.setItem(MODE_STORAGE_KEY, newMode);
        renderCheckboxes(newMode);
    });

    renderCheckboxes(savedMode);

    // ==========================================
    // 5. 主 Copy 逻辑
    // ==========================================
    copyBtn.addEventListener("click", async () => {
        const mode = modeSelect.value;
        const checkedItems = currentCheckboxes
        .filter((item) => item.checked)
        .map((item) => item.name);

        const countRegex = /^(\d+\+?girls?|\d+\+?boys?|multiple girls|multiple boys|1other)$/i;

        const segments = {
            meta: [],
            count: [],
            character: [],
            copyright: [],
            artist: [],
            attrTag: []
        };

        if (checkedItems.length > 0) {
            for (const key in tagsObj) {
                if (Array.isArray(tagsObj[key])) {
                    tagsObj[key].forEach(tag => {
                        if (countRegex.test(tag)) {
                            segments.count.push(tag);
                        }
                    });
                }
            }
        }

        let categorized = {};
        if (mode === "By Type") {
            categorized = await getCategorizedTags();
        }

        checkedItems.forEach(cat => {
            if (!segments[cat]) segments[cat] = [];
            let sourceTags = [];

            if (mode === "Default") {
                sourceTags = tagsObj[cat] || [];
            } else {
                sourceTags = categorized[cat] || tagsObj[cat] || [];
            }

            sourceTags.forEach(tag => {
                if (countRegex.test(tag)) {
                    segments.count.push(tag);
                } else {
                    segments[cat].push(tag);
                }
            });
        });

        if (checkedItems.includes('character') && tagsObj['attrTag']) {
            tagsObj['attrTag'].forEach(tag => {
                if (countRegex.test(tag)) {
                    segments.count.push(tag);
                } else {
                    segments.attrTag.push(tag);
                }
            });
        }

        for (const key in segments) {
            segments[key] = [...new Set(segments[key])];
        }

        if (segments.artist && segments.artist.length > 0) {
            segments.artist = segments.artist.map(tag => ARTIST_PREFIX + tag);
        }

        const finalSegments = [];

        const metaArr = [];
        if (DEFAULT_QUALITY_TAG) metaArr.push(DEFAULT_QUALITY_TAG);
        if (segments.metadata && segments.metadata.length > 0) metaArr.push(segments.metadata.join(", "));
        if (metaArr.length > 0) finalSegments.push(metaArr.join(", "));

        if (segments.count.length > 0) finalSegments.push(segments.count.join(", "));

        const charArr = [];
        if (segments.character && segments.character.length > 0) charArr.push(segments.character.join(", "));
        if (segments.attrTag && segments.attrTag.length > 0) charArr.push(segments.attrTag.join(", "));
        if (charArr.length > 0) finalSegments.push(charArr.join(", "));

        if (segments.copyright && segments.copyright.length > 0) finalSegments.push(segments.copyright.join(", "));

        if (segments.artist && segments.artist.length > 0) finalSegments.push(segments.artist.join(", "));

        const restOrder = ["general", "appearance", "clothing", "pose", "scenery", "other"];

        restOrder.forEach(cat => {
            if (segments[cat] && segments[cat].length > 0) {
                finalSegments.push(segments[cat].join(", "));
            }
        });

        checkedItems.forEach(cat => {
            if (!["metadata", "character", "copyright", "artist", ...restOrder].includes(cat)) {
                if (segments[cat] && segments[cat].length > 0) {
                    finalSegments.push(segments[cat].join(", "));
                }
            }
        });

        const finalTagsString = finalSegments.join(",\n");

        navigator.clipboard.writeText(finalTagsString).then(() => {
            const originalText = copyBtn.innerText;
            copyBtn.innerText = "Copied!";
            setTimeout(() => copyBtn.innerText = originalText, 1000);
            window.notice && window.notice('Tags copied.');
        }).catch((e) => {
            prompt("copied tags:", finalTagsString);
        });
    });
})();