Greasy Fork is available in English.
Hashtag Bundler v2.6 — Combined bundles with auto names, checkbox-based editor, collapsible UI, export/import, and improved UX.
当前为
// ==UserScript==
// @name Hashtag Bundler for X/Twitter and Bluesky
// @namespace https://codymkw.nekoweb.org/
// @version 2.6
// @description Hashtag Bundler v2.6 — Combined bundles with auto names, checkbox-based editor, collapsible UI, export/import, and improved UX.
// @match https://twitter.com/*
// @match https://x.com/*
// @match https://bsky.app/*
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
(function () {
'use strict';
//-------------------------------------------------------
// Setup Host-Matching
//-------------------------------------------------------
let PANEL_ID, STORAGE_KEY;
const host = location.hostname;
if (host === "twitter.com" || host === "x.com") {
PANEL_ID = "twitter-hashtag-panel";
STORAGE_KEY = "twitterHashtagBundles";
} else if (host === "bsky.app") {
PANEL_ID = "bluesky-hashtag-panel";
STORAGE_KEY = "blueskyHashtagBundles";
} else return;
const COMBINED_KEY = STORAGE_KEY + "_combined";
const COLLAPSED_KEY = STORAGE_KEY + "_collapsed";
//-------------------------------------------------------
// Storage Helpers
//-------------------------------------------------------
const loadData = () => JSON.parse(GM_getValue(STORAGE_KEY, "{}") || "{}");
const saveData = (v) => GM_setValue(STORAGE_KEY, JSON.stringify(v));
const loadCombined = () => JSON.parse(GM_getValue(COMBINED_KEY, "{}") || "{}");
const saveCombined = (v) => GM_setValue(COMBINED_KEY, JSON.stringify(v));
const loadCollapsed = () => JSON.parse(GM_getValue(COLLAPSED_KEY, "false"));
const saveCollapsed = (v) => GM_setValue(COLLAPSED_KEY, JSON.stringify(Boolean(v)));
//-------------------------------------------------------
// Helper to Create Elements
//-------------------------------------------------------
const el = (tag, props = {}, kids = []) => {
const e = document.createElement(tag);
for (const k in props) {
if (k === "style" && typeof props[k] === "object") Object.assign(e.style, props[k]);
else if (k === "html") e.innerHTML = props[k];
else e[k] = props[k];
}
kids.forEach(c => e.appendChild(c));
return e;
};
//-------------------------------------------------------
// Auto Combined Name — Option C1
//-------------------------------------------------------
const makeAutoName = (sources) => {
const initials = sources
.map(n => n.split(/[\W_]+/).filter(Boolean)
.map(w => w[0].toUpperCase()).join(""))
.join("_");
const safe = sources.map(s =>
s.replace(/[^\w\-]/g, "_").replace(/_+/g, "_")
);
const ts = Date.now();
return {
key: `Combined_${safe.join("_")}_${ts}`,
label: `Combined_${initials}_${String(ts).slice(-5)}`
};
};
//-------------------------------------------------------
// Confirmation Button Helper — text-only (Option D)
//-------------------------------------------------------
const confirmButton = (btn, originalText, confirmText) => {
btn.textContent = confirmText;
setTimeout(() => btn.textContent = originalText, 3000);
};
//-------------------------------------------------------
// Create the Hashtag Panel
//-------------------------------------------------------
const createPanel = () => {
if (document.getElementById(PANEL_ID)) return;
const panel = el("div", {
id: PANEL_ID,
style: `
position:fixed;right:20px;bottom:20px;width:320px;
background:#1f1f1f;color:white;border-radius:12px;
padding:10px;font-family:sans-serif;
box-shadow:0 4px 14px rgba(0,0,0,0.5);
z-index:999999;
`
});
// Header ----------------------------------------------------------
const header = el("div", {
style: `
display:flex;justify-content:space-between;align-items:center;
cursor:pointer;margin-bottom:8px;
`
});
const title = el("span", {
textContent: "Hashtag Bundles",
style: "font-weight:700;"
});
const arrow = el("span", {
textContent: "▼",
style: "font-size:14px;"
});
header.append(title, arrow);
// Container --------------------------------------------------------
const container = el("div", {
id: "bundle-container",
style: "max-height:380px;overflow-y:auto;padding-right:6px;"
});
panel.append(header, container);
document.body.append(panel);
// Handle collapse persistence
let collapsed = loadCollapsed();
container.style.display = collapsed ? "none" : "block";
arrow.textContent = collapsed ? "▲" : "▼";
header.onclick = () => {
collapsed = !collapsed;
saveCollapsed(collapsed);
container.style.display = collapsed ? "none" : "block";
arrow.textContent = collapsed ? "▲" : "▼";
};
renderPanel();
//-------------------------------------------------------
// Render Bundles inside Panel
//-------------------------------------------------------
function renderPanel() {
container.innerHTML = "";
const data = loadData();
// NEW BUNDLE BUTTON --------------------------------------------------
const newBtn = el("button", {
textContent: "➕ New Bundle",
style: `
width:100%;padding:8px;margin-bottom:8px;border:none;
border-radius:6px;background:#5865F2;color:white;
font-weight:700;cursor:pointer;
`
});
newBtn.onclick = () => {
const name = prompt("Bundle name?");
if (!name) return;
const tags = prompt("Enter hashtags (space separated):");
if (!tags) return;
data[name] = tags.split(/\s+/)
.filter(Boolean)
.map(t => t.startsWith("#") ? t : "#" + t);
saveData(data);
renderPanel();
};
container.append(newBtn);
// COMBINE ------------------------------------------------------------
const combineBtn = el("button", {
textContent: "🔗 Combine Bundles",
style: `
width:100%;padding:8px;margin-bottom:8px;border:none;
border-radius:6px;background:#9b59b6;color:white;
font-weight:700;cursor:pointer;
`
});
combineBtn.onclick = openCreateCombinedModal;
container.append(combineBtn);
// SHOW COMBINED ------------------------------------------------------
const showCombined = el("button", {
textContent: "📂 Show Combined Bundles",
style: `
width:100%;padding:8px;margin-bottom:8px;border:none;
border-radius:6px;background:#8e44ad;color:white;
font-weight:700;cursor:pointer;
`
});
showCombined.onclick = openCombinedPopup;
container.append(showCombined);
// EXPORT / IMPORT ----------------------------------------------------
const wrap = el("div", {
style: "display:flex;gap:8px;margin:10px 0;"
});
const exportBtn = el("button", {
textContent: "Export",
style: `
flex:1;padding:8px;border:none;border-radius:6px;
background:#4CAF50;color:white;font-weight:700;cursor:pointer;
`
});
exportBtn.onclick = () => {
const blob = new Blob([JSON.stringify({
bundles: loadData(),
combined: loadCombined()
}, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = STORAGE_KEY + ".json";
a.click();
};
const importBtn = el("button", {
textContent: "Import",
style: `
flex:1;padding:8px;border:none;border-radius:6px;
background:#2196F3;color:white;font-weight:700;cursor:pointer;
`
});
importBtn.onclick = () => {
const f = document.createElement("input");
f.type = "file";
f.accept = "application/json";
f.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = ev => {
try {
const j = JSON.parse(ev.target.result);
saveData({ ...loadData(), ...(j.bundles || {}) });
saveCombined({ ...loadCombined(), ...(j.combined || {}) });
renderPanel();
alert("Import complete.");
} catch {
alert("Invalid JSON.");
}
};
r.readAsText(file);
};
f.click();
};
wrap.append(exportBtn, importBtn);
container.append(wrap);
// LIST BUNDLES -------------------------------------------------------
const names = Object.keys(data).sort();
names.forEach(name => {
const wrap = el("div", {
style: `
background:#262626;border-radius:8px;padding:8px;
margin-bottom:8px;
`
});
const row = el("div", {
style: `
display:flex;justify-content:space-between;align-items:center;
cursor:pointer;
`
});
const title = el("span", {
textContent: name,
style: "font-weight:700;font-size:14px;"
});
const arrow = el("span", {
textContent: "▶",
style: "font-size:13px;transition:0.18s;"
});
row.append(title, arrow);
wrap.append(row);
const inner = el("div", {
style: `
display:none;margin-top:8px;border-top:1px solid #444;
padding-top:8px;font-size:12px;color:#ccc;
`
});
const tags = el("div", {
textContent: data[name].join(" "),
style: "word-break:break-word;margin-bottom:8px;"
});
const btns = el("div", { style: "display:flex;gap:8px;" });
const mkbtn = (t, bg, fn) =>
el("button", {
textContent: t,
style: `
flex:1;padding:6px;border:none;border-radius:6px;
background:${bg};color:white;font-weight:700;cursor:pointer;
font-size:12px;
`,
onclick: fn
});
// Insert
const insertBtn = mkbtn("Insert", "#2196F3", () => {
const c = document.querySelector("textarea, div[contenteditable='true']");
if (!c) return alert("Composer not found.");
const s = data[name].join(" ") + " ";
if (c.tagName === "TEXTAREA") c.value += s;
else document.execCommand("insertText", false, s);
confirmButton(insertBtn, "Insert", "Inserted!");
});
// Copy
const copyBtn = mkbtn("Copy", "#4CAF50", async () => {
await navigator.clipboard.writeText(data[name].join(" "));
confirmButton(copyBtn, "Copy", "Copied!");
});
// Edit
const editBtn = mkbtn("Edit", "#555", () => {
const tags2 = prompt("Edit hashtags:", data[name].join(" "));
if (!tags2) return;
data[name] = tags2.split(/\s+/)
.filter(Boolean)
.map(t => t.startsWith("#") ? t : "#" + t);
saveData(data);
renderPanel();
});
// Delete
const delBtn = mkbtn("Delete", "#c0392b", () => {
if (!confirm(`Delete "${name}"?`)) return;
delete data[name];
saveData(data);
renderPanel();
});
btns.append(insertBtn, copyBtn, editBtn, delBtn);
inner.append(tags, btns);
wrap.append(inner);
row.onclick = () => {
const open = inner.style.display === "none";
inner.style.display = open ? "block" : "none";
arrow.style.transform = open ? "rotate(90deg)" : "";
};
container.append(wrap);
});
}
};
//-------------------------------------------------------
// Create Combined Bundle (checkbox modal)
//-------------------------------------------------------
const openCreateCombinedModal = () => {
const data = loadData();
const names = Object.keys(data).sort();
if (!names.length) return alert("No bundles available.");
if (document.getElementById("combine-backdrop")) return;
const backdrop = el("div", {
id: "combine-backdrop",
style: `
position:fixed;left:0;top:0;width:100%;height:100%;
background:rgba(0,0,0,0.4);z-index:999999;
`
});
document.body.append(backdrop);
const modal = el("div", {
style: `
position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);
background:#1f1f1f;color:white;width:360px;padding:14px;
border-radius:12px;z-index:1000000;
box-shadow:0 6px 24px rgba(0,0,0,0.6);
`
});
backdrop.append(modal);
backdrop.onclick = (e) => { if (e.target === backdrop) close(); };
modal.onclick = (e) => e.stopPropagation();
modal.append(el("div", {
textContent: "Combine Bundles",
style: "font-weight:700;margin-bottom:10px;font-size:15px;"
}));
let order = [];
const list = el("div", {
style: `
display:flex;flex-direction:column;gap:6px;
margin-bottom:10px;max-height:220px;overflow:auto;
`
});
names.forEach(n => {
const row = el("div", {
style: `
display:flex;align-items:center;gap:8px;background:#262626;
padding:8px;border-radius:6px;cursor:pointer;
`
});
const cb = el("input", { type: "checkbox" });
cb.onclick = (e) => e.stopPropagation();
cb.onchange = () => {
if (cb.checked) order.push(n);
else order = order.filter(x => x !== n);
updatePreview();
};
const lbl = el("span", { textContent: n, style: "font-weight:600;" });
lbl.onclick = () => { cb.checked = !cb.checked; cb.onchange(); };
row.append(cb, lbl);
list.append(row);
});
modal.append(list);
modal.append(el("div", {
textContent: "Preview:",
style: "color:#ccc;margin-bottom:6px;"
}));
const preview = el("div", {
style: `
padding:8px;background:#262626;border-radius:6px;color:#ddd;
min-height:28px;margin-bottom:12px;word-break:break-word;
`
});
modal.append(preview);
function updatePreview() {
const tags = [...new Set(order.flatMap(n => data[n]))];
preview.textContent = tags.join(" ") || "(none)";
}
const createBtn = el("button", {
textContent: "Create Combined Bundle",
style: `
width:100%;padding:8px;background:#27ae60;color:white;
border:none;border-radius:6px;font-weight:700;
cursor:pointer;margin-bottom:8px;
`
});
createBtn.onclick = () => {
if (!order.length) return alert("Select at least one bundle.");
const auto = makeAutoName(order);
const tags = [...new Set(order.flatMap(n => data[n]))];
const combined = loadCombined();
combined[auto.key] = {
label: auto.label,
fullKey: auto.key,
sources: [...order],
tags
};
saveCombined(combined);
alert("Combined bundle created.");
close();
};
const cancelBtn = el("button", {
textContent: "Cancel",
style: `
width:100%;padding:8px;background:#7f8c8d;color:white;
border:none;border-radius:6px;font-weight:700;
cursor:pointer;
`
});
cancelBtn.onclick = close;
modal.append(createBtn, cancelBtn);
function close() { backdrop.remove(); }
};
//-------------------------------------------------------
// Edit Combined Bundle (checkbox modal)
//-------------------------------------------------------
const openEditCombinedModal = (key) => {
const combined = loadCombined();
const data = loadData();
if (!combined[key]) return alert("Not found.");
const combo = combined[key];
const names = Object.keys(data).sort();
if (!names.length) return alert("No bundles exist.");
if (document.getElementById("edit-backdrop")) return;
const backdrop = el("div", {
id: "edit-backdrop",
style: `
position:fixed;left:0;top:0;width:100%;height:100%;
background:rgba(0,0,0,0.4);z-index:999999;
`
});
document.body.append(backdrop);
const modal = el("div", {
style: `
position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);
background:#1f1f1f;color:white;width:360px;padding:14px;
border-radius:12px;z-index:1000000;box-shadow:0 6px 24px rgba(0,0,0,0.6);
`
});
backdrop.append(modal);
backdrop.onclick = (e) => { if (e.target === backdrop) close(); };
modal.onclick = (e) => e.stopPropagation();
modal.append(el("div", {
textContent: "Edit Combined Bundle",
style: "font-weight:700;margin-bottom:10px;font-size:15px;"
}));
modal.append(el("div", {
textContent: `Current label: ${combo.label}`,
style: "color:#bbb;margin-bottom:10px;font-size:13px;"
}));
let order = [...combo.sources];
const list = el("div", {
style: `
display:flex;flex-direction:column;gap:6px;margin-bottom:10px;
max-height:220px;overflow:auto;
`
});
names.forEach(n => {
const row = el("div", {
style: `
display:flex;align-items:center;gap:8px;background:#262626;
padding:8px;border-radius:6px;cursor:pointer;
`
});
const cb = el("input", { type: "checkbox" });
cb.checked = order.includes(n);
cb.onclick = (e) => e.stopPropagation();
cb.onchange = () => {
if (cb.checked) {
if (!order.includes(n)) order.push(n);
} else {
order = order.filter(x => x !== n);
}
updatePreview();
};
const lbl = el("span", { textContent: n, style: "font-weight:600;" });
lbl.onclick = () => { cb.checked = !cb.checked; cb.onchange(); };
row.append(cb, lbl);
list.append(row);
});
modal.append(list);
modal.append(el("div", {
textContent: "Preview:",
style: "color:#ccc;margin-bottom:6px;"
}));
const preview = el("div", {
style: `
padding:8px;background:#262626;border-radius:6px;color:#ddd;
min-height:28px;margin-bottom:12px;word-break:break-word;
`
});
modal.append(preview);
function updatePreview() {
const tags = [...new Set(order.flatMap(n => data[n] || []))];
preview.textContent = tags.join(" ") || "(none)";
}
updatePreview();
const saveBtn = el("button", {
textContent: "Save Changes",
style: `
width:100%;padding:8px;background:#27ae60;color:white;
border:none;border-radius:6px;font-weight:700;
cursor:pointer;margin-bottom:8px;
`
});
saveBtn.onclick = () => {
if (!order.length) return alert("Select at least one bundle.");
const oldKey = key;
const oldSources = combo.sources.join("|");
const newSources = order.join("|");
let newKey = key;
let newLabel = combo.label;
if (oldSources !== newSources) {
const auto = makeAutoName(order);
newKey = auto.key;
newLabel = auto.label;
delete combined[oldKey];
}
const newTags = [...new Set(order.flatMap(n => data[n] || []))];
combined[newKey] = {
label: newLabel,
fullKey: newKey,
sources: [...order],
tags: newTags
};
saveCombined(combined);
alert("Updated.");
close();
};
const cancelBtn = el("button", {
textContent: "Cancel",
style: `
width:100%;padding:8px;background:#7f8c8d;color:white;
border:none;border-radius:6px;font-weight:700;cursor:pointer;
`
});
cancelBtn.onclick = close;
modal.append(saveBtn, cancelBtn);
function close() { backdrop.remove(); }
};
//-------------------------------------------------------
// Combined Bundles Popup (with ⓧ close button)
//-------------------------------------------------------
const openCombinedPopup = () => {
const combined = loadCombined();
const keys = Object.keys(combined).sort((a, b) => {
const la = (combined[a].label || a).toLowerCase();
const lb = (combined[b].label || b).toLowerCase();
return la.localeCompare(lb);
});
if (!keys.length) return alert("No combined bundles exist.");
if (document.getElementById("combined-backdrop")) return;
const backdrop = el("div", {
id: "combined-backdrop",
style: `
position:fixed;left:0;top:0;width:100%;height:100%;
background:rgba(0,0,0,0.4);z-index:999999;
`
});
document.body.append(backdrop);
const popup = el("div", {
id: "combined-popup",
style: `
position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);
background:#1f1f1f;color:white;width:420px;padding:18px;
border-radius:12px;z-index:1000000;max-height:560px;overflow-y:auto;
box-shadow:0 6px 24px rgba(0,0,0,0.6);
`
});
backdrop.append(popup);
backdrop.onclick = (e) => { if (e.target === backdrop) close(); };
popup.onclick = (e) => e.stopPropagation();
// Title
popup.append(el("div", {
textContent: "Combined Bundles",
style: "font-weight:700;margin-bottom:12px;font-size:15px;"
}));
// CIRCLE CLOSE BUTTON (X4)
const closeBtn = el("div", {
html: "❌", // red circle X symbol
style: `
position:absolute;right:12px;top:12px;font-size:22px;
cursor:pointer;color:#fff;opacity:0.85;
user-select:none;
`
});
closeBtn.onclick = () => close();
closeBtn.onmouseenter = () => closeBtn.style.opacity = "1";
closeBtn.onmouseleave = () => closeBtn.style.opacity = "0.85";
popup.append(closeBtn);
// Content
keys.forEach(key => {
const combo = combined[key];
const wrap = el("div", {
style: `
background:#262626;border-radius:8px;
padding:10px;margin-bottom:10px;
`
});
const name = el("div", {
textContent: combo.label,
style: "font-weight:700;font-size:14px;margin-bottom:4px;"
});
const src = el("div", {
textContent: "from: " + combo.sources.join(", "),
style: "color:#bbb;font-size:12px;margin-bottom:8px;"
});
const tags = el("div", {
textContent: combo.tags.join(" "),
style: "color:#ddd;margin-bottom:8px;word-break:break-word;"
});
const btns = el("div", { style: "display:flex;gap:8px;" });
const mkbtn = (t, bg, fn) =>
el("button", {
textContent: t,
onclick: fn,
style: `
flex:1;padding:8px;border:none;border-radius:6px;
background:${bg};color:white;font-weight:700;
cursor:pointer;
`
});
// Insert
const insertBtn = mkbtn("Insert", "#2196F3", () => {
const c = document.querySelector("textarea, div[contenteditable='true']");
if (!c) return alert("Composer not found.");
const s = combo.tags.join(" ") + " ";
if (c.tagName === "TEXTAREA") c.value += s;
else document.execCommand("insertText", false, s);
confirmButton(insertBtn, "Insert", "Inserted!");
});
// Copy
const copyBtn = mkbtn("Copy", "#4CAF50", async () => {
await navigator.clipboard.writeText(combo.tags.join(" "));
confirmButton(copyBtn, "Copy", "Copied!");
});
const editBtn = mkbtn("Edit", "#f39c12", () => openEditCombinedModal(key));
const delBtn = mkbtn("Delete", "#c0392b", () => {
if (!confirm("Delete this combined bundle?")) return;
const all = loadCombined();
delete all[key];
saveCombined(all);
wrap.remove();
});
btns.append(insertBtn, copyBtn, editBtn, delBtn);
wrap.append(name, src, tags, btns);
popup.append(wrap);
});
function close() { backdrop.remove(); }
};
//-------------------------------------------------------
// Observe for composer — auto show panel
//-------------------------------------------------------
let panelVisible = false;
const observer = new MutationObserver(() => {
const c = document.querySelector("textarea, div[contenteditable='true']");
if (c && !panelVisible) {
createPanel();
panelVisible = true;
} else if (!c && panelVisible) {
const p = document.getElementById(PANEL_ID);
if (p) p.remove();
panelVisible = false;
}
});
observer.observe(document.body, { childList: true, subtree: true });
if (document.querySelector("textarea, div[contenteditable='true']")) {
createPanel();
panelVisible = true;
}
})();