Greasy Fork

Greasy Fork is available in English.

Hashtag Bundler for X/Twitter and Bluesky

Simplify hashtags on X/Twitter & Bluesky: group in collapsible bundles, insert/copy, combine bundles in a pop-up, and share via export/import.

当前为 2025-11-14 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name Hashtag Bundler for X/Twitter and Bluesky
// @namespace https://codymkw.nekoweb.org/
// @version 2.3
// @description Simplify hashtags on X/Twitter & Bluesky: group in collapsible bundles, insert/copy, combine bundles in a pop-up, and share via export/import.
// @author Cody
// @match https://twitter.com/*
// @match https://x.com/*
// @match https://bsky.app/*
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    let PANEL_ID, STORAGE_KEY;
    const hostname = window.location.hostname;

    if (hostname === 'twitter.com' || hostname === 'x.com') {
        PANEL_ID = 'twitter-hashtag-panel';
        STORAGE_KEY = 'twitterHashtagBundles';
    } else if (hostname === 'bsky.app') {
        PANEL_ID = 'bluesky-hashtag-panel';
        STORAGE_KEY = 'blueskyHashtagBundles';
    } else return;

    const COMBINED_KEY = STORAGE_KEY + '_combined';
    let panelVisible = false;

    const loadData = () => JSON.parse(GM_getValue(STORAGE_KEY, '{}'));
    const saveData = (d) => GM_setValue(STORAGE_KEY, JSON.stringify(d));
    const loadCombined = () => JSON.parse(GM_getValue(COMBINED_KEY, '{}'));
    const saveCombined = (d) => GM_setValue(COMBINED_KEY, JSON.stringify(d));

    const el = (tag, props = {}, children = []) => {
        const e = document.createElement(tag);
        Object.assign(e, props);
        children.forEach(c => e.appendChild(c));
        return e;
    };

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

        const panel = el('div', {
            id: PANEL_ID,
            style: `
                position: fixed; right: 20px; bottom: 20px;
                width: 280px; background: #1f1f1f; color: white;
                border-radius: 12px; padding: 10px; font-family: sans-serif;
                box-shadow: 0 2px 8px rgba(0,0,0,0.4); z-index: 99999;
            `
        });

        const style = document.createElement("style");
        style.textContent = `
            #bundle-container::-webkit-scrollbar { width: 6px; }
            #bundle-container::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
            #bundle-container::-webkit-scrollbar-track { background: #222; }
        `;
        document.head.appendChild(style);

        const header = el('div', { style: 'font-weight:bold;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;' });
        const title = el('span', { textContent: 'Hashtag Bundles' });
        const toggle = el('span', { textContent: '▼', style: 'font-size:14px;' });
        header.append(title, toggle);

        const container = el('div', { id: 'bundle-container', style: 'overflow-y:auto; max-height:300px; padding-right:3px;' });

        panel.appendChild(header);
        panel.appendChild(container);
        document.body.appendChild(panel);

        let collapsed = false;
        header.onclick = () => {
            collapsed = !collapsed;
            container.style.display = collapsed ? 'none' : 'block';
            toggle.textContent = collapsed ? '▲' : '▼';
        };

        const render = () => {
            container.innerHTML = '';
            const data = loadData();

            // New Bundle Button
            const addBtn = el('button', { textContent:'➕ New Bundle', style:'width:100%;padding:8px;margin-bottom:6px;border:none;border-radius:6px;background:#5865F2;color:white;font-weight:bold;cursor:pointer;' });
            addBtn.onclick = () => {
                const name = prompt('Bundle name?'); if(!name) return;
                const tags = prompt('Enter hashtags separated by spaces — "#" optional:'); if(!tags) return;
                data[name] = tags.split(/\s+/).filter(Boolean).map(t=>t.startsWith('#')?t:'#'+t);
                saveData(data); render();
            };
            container.appendChild(addBtn);

            // Combine Bundles Button (create new combined bundle)
            const combineBtn = el('button', { textContent:'🔗 Combine Bundles', style:'width:100%;padding:8px;margin-bottom:6px;border:none;border-radius:6px;background:#9b59b6;color:white;font-weight:bold;cursor:pointer;' });
            combineBtn.onclick = () => createCombinedBundle(data);
            container.appendChild(combineBtn);

            // Show Combined Bundles Button (pop-up)
            const showCombinedBtn = el('button', { textContent:'📂 Show Combined Bundles', style:'width:100%;padding:8px;margin-bottom:6px;border:none;border-radius:6px;background:#8e44ad;color:white;font-weight:bold;cursor:pointer;' });
            showCombinedBtn.onclick = () => openCombinedPopup();
            container.appendChild(showCombinedBtn);

            // Export / Import
            const ioControls = el('div',{style:'display:flex;gap:6px;margin-bottom:10px;'});
            const makeIOBtn = (label,bg) => el('button',{textContent:label,style:`flex:1;padding:8px;border:none;border-radius:6px;background:${bg};color:white;font-weight:bold;cursor:pointer;`});
            const exportBtn = makeIOBtn('Export','#4CAF50');
            const importBtn = makeIOBtn('Import','#2196F3');

            exportBtn.onclick = () => {
                const blob = new Blob([JSON.stringify(loadData(),null,2)],{type:'application/json'});
                const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href=url; a.download=`${STORAGE_KEY}.json`; a.click(); URL.revokeObjectURL(url);
            };
            importBtn.onclick = () => {
                const input = document.createElement('input'); input.type='file'; input.accept='application/json';
                input.onchange = (e)=>{ const file=e.target.files[0]; if(!file) return;
                    const reader=new FileReader(); reader.onload=(ev)=>{ try { const imported=JSON.parse(ev.target.result); saveData({...loadData(),...imported}); render(); alert('Import OK!'); } catch{ alert('Invalid JSON.'); } }; reader.readAsText(file);
                }; input.click();
            };
            ioControls.append(exportBtn,importBtn); container.appendChild(ioControls);

            // Render Bundles
            Object.keys(data).forEach(bundleName => {
                const entry = el('div',{style:'background:#262626;border-radius:8px;margin-bottom:8px;padding:6px 8px;'});
                const row = el('div',{style:'display:flex;justify-content:space-between;align-items:center;cursor:pointer;'});
                const nameEl = el('span',{textContent:bundleName,style:'font-weight:bold;font-size:14px;'});
                const arrow = el('span',{textContent:'▶',style:'font-size:13px;transition:0.2s;'});
                row.append(nameEl,arrow); entry.appendChild(row);

                const inner = el('div',{style:'display:none;margin-top:6px;padding-top:6px;border-top:1px solid #444;font-size:12px;color:#ccc;'});
                const tagText = el('div',{textContent:data[bundleName].join(' '),style:'overflow-wrap:anywhere;margin-bottom:6px;'});

                const controls = el('div',{style:'display:flex;gap:6px;'});
                const makeBtn = (label,bg) => el('button',{textContent:label,style:`padding:4px 6px;border:none;border-radius:4px;background:${bg};color:white;cursor:pointer;font-size:11px;`});
                const insertBtn = makeBtn('Insert','#2196F3'); const copyBtn=makeBtn('Copy','#4CAF50'); const editBtn=makeBtn('Edit','#555'); const delBtn=makeBtn('Delete','#c0392b');

                insertBtn.onclick=()=>{ const composer=document.querySelector('div[contenteditable="true"]'); if(!composer) return alert('Composer not found.'); composer.focus(); document.execCommand('insertText',false,data[bundleName].join(' ')+' '); insertBtn.textContent='Inserted!'; setTimeout(()=>insertBtn.textContent='Insert',800); };
                copyBtn.onclick=async()=>{ await navigator.clipboard.writeText(data[bundleName].join(' ')).catch(()=>alert('Copy error')); copyBtn.textContent='Copied!'; setTimeout(()=>copyBtn.textContent='Copy',800); };
                editBtn.onclick=()=>{ const newTags=prompt('Edit hashtags separated by spaces — "#" optional:',data[bundleName].join(' ')); if(!newTags) return; data[bundleName]=newTags.split(/\s+/).filter(Boolean).map(t=>t.startsWith('#')?t:'#'+t); saveData(data); render(); };
                delBtn.onclick=()=>{ if(!confirm(`Delete "${bundleName}"?`)) return; delete data[bundleName]; saveData(data); render(); };

                controls.append(insertBtn,copyBtn,editBtn,delBtn); inner.append(tagText,controls); entry.appendChild(inner);
                row.onclick=()=>{ const show=inner.style.display==='none'; inner.style.display=show?'block':'none'; arrow.style.transform=show?'rotate(90deg)':'rotate(0deg)'; };
                container.appendChild(entry);
            });
        };

        render(); panelVisible=true;
    };

    const removePanel = () => { const p=document.getElementById(PANEL_ID); if(p)p.remove(); panelVisible=false; };

    // Create Combined Bundle via prompts
    const createCombinedBundle = (data) => {
        const bundleNames = Object.keys(data); if(!bundleNames.length) return alert('No bundles to combine.');
        const selected=prompt(`Enter bundle names to combine, separated by commas:\nAvailable: ${bundleNames.join(', ')}`); if(!selected) return;
        const names=selected.split(',').map(n=>n.trim()).filter(Boolean); if(!names.every(n=>bundleNames.includes(n))) return alert('Invalid bundle names.');
        const combinedTags=[...new Set(names.flatMap(n=>data[n]))];
        const newName=prompt('Name for the new combined bundle?'); if(!newName) return;
        const combinedData=loadCombined(); combinedData[newName]={tags:combinedTags,sources:names}; saveCombined(combinedData);
        alert(`Combined bundle "${newName}" created!`);
    };

    // Open centered pop-up for combined bundles
    const openCombinedPopup = () => {
        const combinedData=loadCombined(); if(!Object.keys(combinedData).length) return alert('No combined bundles yet.');
        if(document.getElementById('combined-popup')) return;
        const popup=el('div',{id:'combined-popup',style:'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:320px;max-height:400px;background:#1f1f1f;color:white;border-radius:12px;padding:12px;overflow-y:auto;box-shadow:0 2px 12px rgba(0,0,0,0.5);z-index:100000;'});
        const header=el('div',{style:'display:flex;justify-content:space-between;font-weight:bold;margin-bottom:8px;'}); const title=el('span',{textContent:'Combined Bundles'}); const closeBtn=el('span',{textContent:'✖',style:'cursor:pointer'}); closeBtn.onclick=()=>popup.remove(); header.append(title,closeBtn); popup.appendChild(header);

        Object.keys(combinedData).forEach(name=>{
            const combo=combinedData[name];
            const entry=el('div',{style:'background:#262626;border-radius:8px;padding:6px;margin-bottom:6px;'});
            const nameEl=el('div',{textContent:`${name} (from: ${combo.sources.join(', ')})`, style:'font-weight:bold;font-size:13px;margin-bottom:4px;'});
            const tagText=el('div',{textContent:combo.tags.join(' '),style:'overflow-wrap:anywhere;font-size:12px;margin-bottom:4px;'});
            const controls=el('div',{style:'display:flex;gap:6px;'});
            const makeBtn=(text,bg,onClick)=>el('button',{textContent:text,style:`flex:1;padding:4px;border-radius:4px;background:${bg};color:white;cursor:pointer;font-size:11px;`,onclick:onClick});
            const insertBtn=makeBtn('Insert','#2196F3',()=>{const c=document.querySelector('div[contenteditable="true"]'); if(c) document.execCommand('insertText',false,combo.tags.join(' ')+' ');});
            const copyBtn=makeBtn('Copy','#4CAF50',async()=>{await navigator.clipboard.writeText(combo.tags.join(' ')).catch(()=>alert('Copy error'));});
            const delBtn=makeBtn('Delete','#c0392b',()=>{delete combinedData[name]; saveCombined(combinedData); entry.remove();});
            controls.append(insertBtn,copyBtn,delBtn); entry.append(nameEl,tagText,controls); popup.appendChild(entry);
        });

        document.body.appendChild(popup);
    };

    const observer = new MutationObserver(()=>{ const composer=document.querySelector('div[contenteditable="true"]'); if(composer && !panelVisible) createPanel(); else if(!composer && panelVisible) removePanel(); });
    observer.observe(document.body,{childList:true,subtree:true});

})();