复制 Gelbooru 的 tags,支持自定义选择 tag 类型 / Quickly copy tags from gelbooru's post so you can use thoes tags to generate AI images.
// ==UserScript==
// @name Gelbooru Tag Copyer
// @namespace https://greasyfork.org/zh-CN/scripts/439308
// @version 2.3
// @description 复制 Gelbooru 的 tags,支持自定义选择 tag 类型 / Quickly copy tags from gelbooru's post so you can use thoes tags to generate AI images.
// @author 3989364 (Modified)
// @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"];
/**
* @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;
}
function createTagCheckbox(tagType, checked = false, label = "") {
const el = document.createElement("div");
el.innerHTML = `
<input
type="checkbox"
name="${tagType}"
${checked ? "checked" : ""}> <span style="margin-left:4px;">${label || tagType}</span>
`;
el.style.marginBottom = "0.25rem";
el.style.display = "flex";
el.style.alignItems = "center";
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";
copyBtn.style.padding = "2px 8px";
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: []
};
// 【新增逻辑】只要有勾选项,就全局扫描所有 tags 获取人数/性别标签
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();
}
// 步骤 1: 将勾选项对应的 Tag 分配到具体的 Bucket 里
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)) {
// 已在上方全局提取,这里跳过,或者 push 后通过 Set 去重也可
segments.count.push(tag);
} else {
segments[cat].push(tag);
}
});
});
// 步骤 2: 保持特殊逻辑,选中角色时带出 attrTag (如 hair/eyes)
if (checkedItems.includes('character') && tagsObj['attrTag']) {
tagsObj['attrTag'].forEach(tag => {
if (countRegex.test(tag)) {
segments.count.push(tag);
} else {
segments.attrTag.push(tag);
}
});
}
// 步骤 3: 内部去重
for (const key in segments) {
segments[key] = [...new Set(segments[key])];
}
// 步骤 4: 处理画师标签添加前缀
if (segments.artist && segments.artist.length > 0) {
segments.artist = segments.artist.map(tag => ARTIST_PREFIX + tag);
}
// 步骤 5: 按规则顺序进行组装
const finalSegments = [];
// 第 1 段: Quality / Metadata
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(", "));
// 第 2 段: 人数与性别 (Count)
if (segments.count.length > 0) finalSegments.push(segments.count.join(", "));
// 第 3 段: 角色特征 (Character + attrTag)
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(", "));
// 第 4 段: 来源作品 (Series/Copyright)
if (segments.copyright && segments.copyright.length > 0) finalSegments.push(segments.copyright.join(", "));
// 第 5 段: 画师 (Artist)
if (segments.artist && segments.artist.length > 0) finalSegments.push(segments.artist.join(", "));
// 第 6 段开始: 按照 Checkbox 类别,依次分段输出剩余的勾选项
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(", "));
}
}
});
// 最终拼接:各段之间通过逗号加回车 `,\n` 进行隔开
const finalTagsString = finalSegments.join(",\n");
// 写入剪贴板
navigator.clipboard.writeText(finalTagsString).then(() => {
window.notice && window.notice('Tags copied.');
}).catch((e) => {
prompt("copied tags:", finalTagsString);
});
});
})();