Greasy Fork

Greasy Fork is available in English.

Hashtag Bundler for X/Twitter and Bluesky (Unified)

Supercharge your hashtag workflow with a unified floating panel for both X and Bluesky. Create, combine, and sync bundles across both platforms instantly.

当前为 2025-12-19 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Hashtag Bundler for X/Twitter and Bluesky (Unified)
// @namespace    https://codymkw.nekoweb.org/
// @version      2.6.4
// @description  Supercharge your hashtag workflow with a unified floating panel for both X and Bluesky. Create, combine, and sync bundles across both platforms instantly.
// @match        https://twitter.com/*
// @match        https://x.com/*
// @match        https://bsky.app/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ------------------------------
    // Unified Storage Setup
    // ------------------------------
    // We use one key for both sites so your tags are always synced
    const PANEL_ID = "universal-hashtag-panel";
    const STORAGE_KEY = "universalHashtagBundles";
    
    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
    // ------------------------------
    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}` };
    };

    // ------------------------------
    // Composer detection
    // ------------------------------
    function getComposerTarget() {
        let node = document.querySelector('div[data-testid="tweetTextarea_0"]');
        if (node) {
            const inner = node.querySelector('div[contenteditable="true"], [contenteditable="true"]');
            if (inner) return { type: 'contenteditable', node: inner };
            if (node.getAttribute && node.getAttribute('contenteditable') === 'true') return { type: 'contenteditable', node };
        }
        const generic = document.querySelector('div[contenteditable="true"], [contenteditable="true"]');
        if (generic) return { type: 'contenteditable', node: generic };
        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);
            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') {
            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;
            moveCaretToEnd(node);
            try { node.focus(); } catch (e) {}
            try {
                const success = document.execCommand('insertText', false, text);
                if (success) return true;
                node.textContent = node.textContent + text;
                return true;
            } catch (err) {
                try { node.textContent = node.textContent + text; return true; } catch (ee) { return false; }
            }
        }
        return false;
    }

    const confirmButton = (btn, originalText, confirmText) => {
        try { btn.textContent = confirmText; } catch {}
        setTimeout(() => { try { btn.textContent = originalText; } catch {} }, 3000);
    };

    // ------------------------------
    // Panel creation & rendering
    // ------------------------------
    const createPanel = () => {
        if (document.getElementById(PANEL_ID)) return;

        const panel = el("div", {
            id: PANEL_ID,
            style: `
                position:fixed;right:20px;bottom:20px;width:340px;
                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:520px;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();
            const combined = loadCombined();

            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);

            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);

            const showCB = 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;" });
            showCB.onclick = openCombinedPopup;
            container.append(showCB);

            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;" });
            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 = "UniversalHashtagBundles.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 || {}) });
                            render();
                            alert("Import complete.");
                        } catch { alert("Invalid JSON."); }
                    };
                    r.readAsText(file);
                };
                f.click();
            };
            ioWrap.append(exportBtn, importBtn);
            container.append(ioWrap);

            const resetBtn = el("button", { textContent: "⚠️ Reset All Data", style: "width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#c0392b;color:white;font-weight:700;cursor:pointer;" });
            resetBtn.onclick = () => {
                if (!confirm("Permanently delete ALL bundles?")) return;
                GM_deleteValue(STORAGE_KEY);
                GM_deleteValue(COMBINED_KEY);
                GM_deleteValue(COLLAPSED_KEY);
                render();
                alert("All data cleared.");
            };
            container.append(resetBtn);

            const selectLabel = el("div", { textContent: "Your Bundles", style: "font-weight:700;margin:6px 0 4px 0;" });
            const select = el("select", { id: "bundle-select", style: "width:100%;padding:8px;border-radius:6px;background:#262626;color:#fff;border:none;margin-bottom:8px;" });
            select.append(el("option", { textContent: "-- Select a bundle --", value: "" }));

            Object.keys(data).sort().forEach(n => select.append(el("option", { textContent: n, value: n })));
            container.append(selectLabel, select);

            const detail = el("div", { id: "bundle-detail", style: "background:#262626;border-radius:8px;padding:10px;margin-bottom:8px;display:none;" });
            container.append(detail);

            function renderDetail(bundleName) {
                detail.innerHTML = "";
                if (!bundleName) { detail.style.display = "none"; return; }
                const tagsArr = data[bundleName] || [];
                const nameEl = el("div", { textContent: bundleName, style: "font-weight:700;margin-bottom:6px;" });
                const tagsEl = el("div", { textContent: tagsArr.join(" "), style: "color:#ddd;margin-bottom:8px;word-break:break-word;" });
                const btnRow = el("div", { style: "display:flex;gap:8px;" });
                const mkbtn = (txt, bg, fn) => el("button", { textContent: txt, onclick: fn, style: `flex:1;padding:8px;border:none;border-radius:6px;background:${bg};color:white;font-weight:700;cursor:pointer;` });

                const insertBtn = mkbtn("Insert", "#2196F3", () => {
                    if (insertTextAtEnd(tagsArr.join(" ") + " ")) confirmButton(insertBtn, "Insert", "Inserted!");
                    else alert("Insert failed (composer not found).");
                });

                const copyBtn = mkbtn("Copy", "#4CAF50", async () => {
                    await navigator.clipboard.writeText(tagsArr.join(" ")).catch(() => alert("Copy error"));
                    confirmButton(copyBtn, "Copy", "Copied!");
                });

                const editBtn = mkbtn("Edit", "#555", () => {
                    const tags = prompt("Edit hashtags:", tagsArr.join(" "));
                    if (!tags) return;
                    data[bundleName] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith("#") ? t : "#" + t);
                    saveData(data);
                    renderDetail(bundleName);
                });

                const delBtn = mkbtn("Delete", "#c0392b", () => {
                    if (!confirm(`Delete "${bundleName}"?`)) return;
                    delete data[bundleName];
                    saveData(data);
                    render();
                });

                btnRow.append(insertBtn, copyBtn, editBtn, delBtn);
                detail.append(nameEl, tagsEl, btnRow);
                detail.style.display = "block";
            }

            select.onchange = () => renderDetail(select.value);

            if (!Object.keys(data).length) {
                container.append(el("div", { textContent: "No bundles yet.", style: "color:#999;margin:8px 0;" }));
            }

            const combinedCount = Object.keys(combined).length;
            container.append(el("div", { textContent: `${combinedCount} combined bundle${combinedCount === 1 ? "" : "s"}.`, style: "font-size:12px;color:#bbb;margin-top:6px;" }));
        }
    };

    // ------------------------------
    // Modal Helpers (Combined)
    // ------------------------------
    const openCreateCombinedModal = () => {
        const data = loadData();
        const bundles = Object.keys(data).sort();
        if (!bundles.length) return alert("No bundles available.");
        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;" });
        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);" });
        document.body.append(backdrop); backdrop.append(modal);

        modal.append(el("div", { textContent: "Combine Bundles", style: "font-weight:700;margin-bottom:10px;" }));
        let order = [];
        const list = el("div", { style: "max-height:220px;overflow:auto;margin-bottom:10px;" });
        bundles.forEach(n => {
            const row = el("div", { style: "display:flex;align-items:center;gap:8px;background:#262626;padding:8px;border-radius:6px;margin-bottom:4px;" });
            const cb = el("input", { type: "checkbox" });
            cb.onchange = () => { if (cb.checked) order.push(n); else order = order.filter(x => x !== n); updatePreview(); };
            row.append(cb, el("span", { textContent: n }));
            list.append(row);
        });

        const preview = el("div", { style: "padding:8px;background:#262626;border-radius:6px;min-height:28px;margin-bottom:12px;word-break:break-word;font-size:12px;" });
        const updatePreview = () => { preview.textContent = [...new Set(order.flatMap(n => data[n] || []))].join(" ") || "(none)"; };
        
        const createBtn = el("button", { textContent: "Create", style: "width:100%;padding:8px;background:#27ae60;color:white;border:none;border-radius:6px;margin-bottom:8px;cursor:pointer;" });
        createBtn.onclick = () => {
            const auto = makeAutoName(order);
            const combined = loadCombined();
            combined[auto.key] = { label: auto.label, fullKey: auto.key, sources: [...order], tags: [...new Set(order.flatMap(n => data[n] || []))] };
            saveCombined(combined);
            backdrop.remove();
        };

        modal.append(list, el("div", { textContent: "Preview:" }), preview, createBtn);
        backdrop.onclick = (e) => { if (e.target === backdrop) backdrop.remove(); };
    };

    const openCombinedPopup = () => {
        const combined = loadCombined();
        const keys = Object.keys(combined).sort();
        if (!keys.length) return alert("No combined bundles.");
        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;" });
        const popup = el("div", { 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;" });
        document.body.append(backdrop); backdrop.append(popup);

        const closeBtn = el("div", { html: "❌", style: "position:absolute;right:12px;top:12px;font-size:22px;cursor:pointer;" });
        closeBtn.onclick = () => backdrop.remove();
        popup.append(el("div", { textContent: "Combined Bundles", style: "font-weight:700;margin-bottom:12px;" }), closeBtn);

        keys.forEach(key => {
            const combo = combined[key];
            const wrap = el("div", { style: "background:#262626;border-radius:8px;padding:10px;margin-bottom:10px;" });
            const btnRow = 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;cursor:pointer;` });

            const ins = mkbtn("Insert", "#2196F3", () => insertTextAtEnd(combo.tags.join(" ") + " "));
            const del = mkbtn("Delete", "#c0392b", () => { delete combined[key]; saveCombined(combined); wrap.remove(); });

            btnRow.append(ins, del);
            wrap.append(el("div", { textContent: combo.label, style: "font-weight:700;" }), el("div", { textContent: combo.tags.join(" "), style: "font-size:12px;color:#ccc;margin:4px 0;" }), btnRow);
            popup.append(wrap);
        });

        backdrop.onclick = (e) => { if (e.target === backdrop) backdrop.remove(); };
    };

    // ------------------------------
    // Observer Logic
    // ------------------------------
    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; }

})();