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-26 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 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.8
// @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';

    const PANEL_ID = "universal-hashtag-panel";
    const STORAGE_KEY = "universalHashtagBundles";
    const COMBINED_KEY = STORAGE_KEY + "_combined";
    const COLLAPSED_KEY = STORAGE_KEY + "_collapsed";

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

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

    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);
        // Removed "Combined_" prefix from label as requested
        return { key: `Combined_${safeNames.join("_")}_${ts}`, label: `${initials}_${shortTs}` };
    };

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

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

    const createPanel = () => {
        if (document.getElementById(PANEL_ID)) return;

        const panel = el("div", {
            id: PANEL_ID,
            style: `
                position:fixed;right:20px;
                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;transition: all 0.2s ease-in-out;
            `
        });

        const header = el("div", { style: "display:flex;justify-content:space-between;align-items:center;cursor:pointer;" });
        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;margin-top:8px;" });
        panel.append(header, container);
        document.body.append(panel);

        let collapsed = loadCollapsed();

        const updatePanelLook = (isCollapsed) => {
            container.style.display = isCollapsed ? "none" : "block";
            arrow.textContent = isCollapsed ? "▲" : "▼";
            panel.style.bottom = isCollapsed ? "35px" : "20px";
            panel.style.width = isCollapsed ? "200px" : "340px";
            panel.style.padding = isCollapsed ? "8px 12px" : "10px";
        };

        updatePanelLook(collapsed);

        header.onclick = () => {
            collapsed = !collapsed;
            saveCollapsed(collapsed);
            updatePanelLook(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 newName = prompt("Edit bundle name:", bundleName);
                    if (!newName) return;
                    const tags = prompt("Edit hashtags:", tagsArr.join(" "));
                    if (!tags) return;

                    // If the name changed, remove the old entry
                    if (newName !== bundleName) {
                        delete data[bundleName];
                    }
                    data[newName] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith("#") ? t : "#" + t);
                    saveData(data);
                    render(); // Re-render main list to update select dropdown
                    renderDetail(newName); // Show new details
                });

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

    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 = () => {
            if (!order.length) return alert("Select at least one bundle.");
            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);
            // Strips "Combined_" from display label if it somehow persists from older versions
            const displayLabel = combo.label.replace(/^Combined_/, "");
            wrap.append(el("div", { textContent: displayLabel, 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(); };
    };

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

})();