Greasy Fork is available in English.
Hashtag Bundler v2.6.2 — fixed duplication on Twitter; insert always appends; combined bundles, checkbox editor, collapse, export/import, improved UX.
当前为
// ==UserScript==
// @name Hashtag Bundler for X/Twitter and Bluesky
// @namespace https://codymkw.nekoweb.org/
// @version 2.6.2
// @description Hashtag Bundler v2.6.2 — fixed duplication on Twitter; insert always appends; combined bundles, checkbox editor, collapse, export/import, 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';
//-------------------------------------------------------
// Host / Storage setup
//-------------------------------------------------------
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 = () => {
try { return JSON.parse(GM_getValue(STORAGE_KEY, "{}") || "{}"); } catch { return {}; }
};
const saveData = (v) => GM_setValue(STORAGE_KEY, JSON.stringify(v));
const loadCombined = () => {
try { return JSON.parse(GM_getValue(COMBINED_KEY, "{}") || "{}"); } catch { return {}; }
};
const saveCombined = (v) => GM_setValue(COMBINED_KEY, JSON.stringify(v));
const loadCollapsed = () => {
try { return JSON.parse(GM_getValue(COLLAPSED_KEY, "false")); } catch { return false; }
};
const saveCollapsed = (v) => GM_setValue(COLLAPSED_KEY, JSON.stringify(Boolean(v)));
//-------------------------------------------------------
// DOM helper
//-------------------------------------------------------
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 getInitials = (s) => {
const parts = s.split(/[\W_]+/).filter(Boolean);
if (!parts.length) return s.slice(0, 3).toUpperCase();
return parts.map(p => p[0].toUpperCase()).slice(0, 4).join('');
};
const safeSourceKey = (s) => s.replace(/[^\w\-]/g, "_").replace(/_+/g, "_");
const initials = sources.map(getInitials).join("_");
const safeNames = sources.map(safeSourceKey);
const ts = Date.now();
const shortTs = String(ts).slice(-5);
return { key: `Combined_${safeNames.join("_")}_${ts}`, label: `Combined_${initials}_${shortTs}` };
};
//-------------------------------------------------------
// Insert helper: ensure insertion is appended to end for contenteditable
// - For contenteditable: move caret to end, focus, execCommand('insertText') -> NO InputEvent dispatch
// - If execCommand fails on contenteditable: fallback to textContent += text (no event)
// - For textarea: append to .value and dispatch 'input' event (kept)
//-------------------------------------------------------
function getComposerTarget() {
// 1) Twitter structure B: div[data-testid="tweetTextarea_0"] -> inner contenteditable div
let node = document.querySelector('div[data-testid="tweetTextarea_0"]');
if (node) {
// try find inner contenteditable div
const inner = node.querySelector('div[contenteditable="true"], [contenteditable="true"]');
if (inner) return { type: 'contenteditable', node: inner };
// fallback: maybe node itself is contenteditable
if (node.getAttribute && node.getAttribute('contenteditable') === 'true') return { type: 'contenteditable', node };
}
// 2) generic contenteditable
const generic = document.querySelector('div[contenteditable="true"], [contenteditable="true"]');
if (generic) return { type: 'contenteditable', node: generic };
// 3) textarea fallback
const ta = document.querySelector('textarea');
if (ta) return { type: 'textarea', node: ta };
return null;
}
function moveCaretToEnd(contentEditableNode) {
try {
const range = document.createRange();
range.selectNodeContents(contentEditableNode);
range.collapse(false); // move to end
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch (err) {
// ignore
}
}
function insertTextAtEnd(text) {
const target = getComposerTarget();
if (!target) return false;
if (target.type === 'textarea') {
// append to textarea value and dispatch input
const ta = target.node;
try { ta.focus(); } catch {}
ta.value = ta.value + text;
try { ta.dispatchEvent(new Event('input', { bubbles: true })); } catch {}
return true;
} else if (target.type === 'contenteditable') {
const node = target.node;
// move caret to end first (forces append)
moveCaretToEnd(node);
try { node.focus(); } catch (e) {}
try {
// execCommand insertText (v2.3 style)
const success = document.execCommand('insertText', false, text);
// do NOT dispatch InputEvent afterwards (prevents duplication on Twitter)
if (success) return true;
// if execCommand returned false, fallback to changing textContent (no event)
node.textContent = node.textContent + text;
return true;
} catch (err) {
// fallback: append textContent (no event)
try {
node.textContent = node.textContent + text;
return true;
} catch (ee) {
return false;
}
}
}
return false;
}
//-------------------------------------------------------
// Simple confirm text helper (text-only) - resets after 3s
//-------------------------------------------------------
const confirmButton = (btn, originalText, confirmText) => {
try { btn.textContent = confirmText; } catch {}
setTimeout(() => { try { btn.textContent = originalText; } catch {} }, 3000);
};
//-------------------------------------------------------
// Panel creation & render (v2.6.2)
//-------------------------------------------------------
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;
`
});
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);
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);
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 ? '▲' : '▼';
};
render();
function render() {
container.innerHTML = '';
const data = loadData();
// New Bundle
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 separated by spaces:'); if (!tags) return;
data[name] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : '#' + t);
saveData(data);
render();
};
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 ioWrap = 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;' });
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;' });
exportBtn.onclick = () => {
const payload = { bundles: loadData(), combined: loadCombined() };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = STORAGE_KEY + '.json';
a.click();
};
importBtn.onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e) => {
const f = e.target.files?.[0];
if (!f) return;
const r = new FileReader();
r.onload = (ev) => {
try {
const j = JSON.parse(ev.target.result);
saveData({ ...loadData(), ...(j.bundles || {}) });
saveCombined({ ...loadCombined(), ...(j.combined || {}) });
render();
alert('Import OK.');
} catch {
alert('Invalid JSON.');
}
};
r.readAsText(f);
};
input.click();
};
ioWrap.append(exportBtn, importBtn);
container.append(ioWrap);
// list bundles
const names = Object.keys(data).sort((a, b) => a.localeCompare(b));
if (!names.length) container.append(el('div', { textContent: 'No bundles yet.', style: 'color:#999;font-size:13px;padding:6px;' }));
names.forEach(name => {
const entry = el('div', { style: 'background:#262626;border-radius:8px;margin-bottom:8px;padding:8px;' });
const row = el('div', { style: 'display:flex;justify-content:space-between;align-items:center;cursor:pointer;' });
const nameEl = 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(nameEl, arrow);
entry.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 tagText = el('div', { textContent: data[name].join(' '), style: 'margin-bottom:8px;word-break:break-word;' });
const controls = el('div', { style: 'display:flex;gap:8px;' });
const mkbtn = (txt, bg, fn) => el('button', { textContent: txt, style: `flex:1;padding:6px;border:none;border-radius:6px;background:${bg};color:white;font-size:12px;font-weight:700;cursor:pointer;`, onclick: fn });
// Insert (uses robust insertTextAtEnd)
const insertBtn = mkbtn('Insert', '#2196F3', () => {
const text = data[name].join(' ') + ' ';
const ok = insertTextAtEnd(text);
if (!ok) return alert('Insert failed (composer not found).');
confirmButton(insertBtn, 'Insert', 'Inserted!');
});
// Copy
const copyBtn = mkbtn('Copy', '#4CAF50', async () => {
await navigator.clipboard.writeText(data[name].join(' ')).catch(() => alert('Copy error'));
confirmButton(copyBtn, 'Copy', 'Copied!');
});
// Edit
const editBtn = mkbtn('Edit', '#555', () => {
const tags = prompt('Edit hashtags:', data[name].join(' '));
if (!tags) return;
data[name] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : '#' + t);
saveData(data);
render();
});
// Delete
const delBtn = mkbtn('Delete', '#c0392b', () => {
if (!confirm(`Delete "${name}"?`)) return;
delete data[name];
saveData(data);
render();
});
controls.append(insertBtn, copyBtn, editBtn, delBtn);
inner.append(tagText, controls);
entry.append(inner);
row.onclick = () => {
const show = inner.style.display === "none";
inner.style.display = show ? "block" : "none";
arrow.style.transform = show ? "rotate(90deg)" : "rotate(0deg)";
};
container.append(entry);
});
}
};
//-------------------------------------------------------
// Create Combined Modal (checkbox based)
//-------------------------------------------------------
const openCreateCombinedModal = () => {
const data = loadData();
const bundles = Object.keys(data).sort();
if (!bundles.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;' }));
modal.append(el('div', { textContent: 'Select bundles (order of checking determines tag order):', style: 'color:#ccc;margin-bottom:8px;font-size:13px;' }));
let order = [];
const list = el('div', { style: 'display:flex;flex-direction:column;gap:6px;margin-bottom:10px;max-height:220px;overflow:auto;' });
bundles.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;cursor:pointer;' });
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('Created combined bundle.');
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);
updatePreview();
function close() { const b = document.getElementById('combine-backdrop'); if (b) b.remove(); }
};
//-------------------------------------------------------
// Edit Combined (E1 checkbox edit)
//-------------------------------------------------------
const openEditCombinedModal = (key) => {
const combined = loadCombined();
const data = loadData();
const combo = combined[key];
if (!combo) return alert('Not found.');
const bundles = Object.keys(data).sort();
if (!bundles.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;' });
bundles.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;cursor:pointer;' });
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() { const b = document.getElementById('edit-backdrop'); if (b) b.remove(); }
};
//-------------------------------------------------------
// Combined Bundles Popup with circle close (ⓧ)
//-------------------------------------------------------
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();
popup.append(el('div', { textContent: 'Combined Bundles', style: 'font-weight:700;margin-bottom:12px;font-size:15px;' }));
// circle close button (22px)
const closeBtn = el('div', { html: '❌', 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);
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 uses the robust append method
const insertBtn = mkbtn('Insert', '#2196F3', () => {
const s = combo.tags.join(' ') + ' ';
const ok = insertTextAtEnd(s);
if (!ok) return alert('Insert failed (composer not found).');
confirmButton(insertBtn, 'Insert', 'Inserted!');
});
const copyBtn = mkbtn('Copy', '#4CAF50', async () => {
await navigator.clipboard.writeText(combo.tags.join(' ')).catch(() => alert('Copy error'));
confirmButton(copyBtn, 'Copy', 'Copied!');
});
const editBtn = mkbtn('Edit', '#f39c12', () => openEditCombinedModal(key));
const delBtn = mkbtn('Delete', '#c0392b', () => {
if (!confirm(`Delete "${combo.label}"?`)) 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() { const b = document.getElementById('combined-backdrop'); if (b) b.remove(); }
};
//-------------------------------------------------------
// Auto-show panel when composer present
//-------------------------------------------------------
let panelVisible = false;
const observer = new MutationObserver(() => {
const c = document.querySelector('div[contenteditable="true"], textarea');
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('div[contenteditable="true"], textarea')) {
createPanel();
panelVisible = true;
}
})();