您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
BlueskyとTokimekiのURLを、ボタン、キーボードショートカット、右クリックメニューで切り替え。
// ==UserScript== // @name Bluesky⇔Tokimeki 切り替え // @namespace https://bsky.app/profile/neon-ai.art // @homepage https://bsky.app/profile/neon-ai.art // @icon data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛄️</text></svg> // @version 7.5 // @description BlueskyとTokimekiのURLを、ボタン、キーボードショートカット、右クリックメニューで切り替え。 // @author ねおん // @match https://bsky.app/* // @match https://tokimeki.blue/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @license CC BY-NC 4.0 // ==/UserScript== (function() { 'use strict'; // --- バージョン情報 --- const SCRIPT_VERSION = "7.5"; // 設定を保存するキー const CONFIG_KEY = 'bskyTokimekiConfig'; // 現在のドメインを判定 function getCurrentDomain() { if (window.location.hostname.includes('bsky.app')) { return 'bsky'; } else if (window.location.hostname.includes('tokimeki.blue')) { return 'tokimeki'; } return null; } // --- カスタムアラート関数 --- function showMessage(message) { const overlay = document.createElement('div'); Object.assign(overlay.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: '99999', display: 'flex', justifyContent: 'center', alignItems: 'center', fontFamily: 'Inter, sans-serif' }); const container = document.createElement('div'); Object.assign(container.style, { backgroundColor: '#1a1a1a', color: '#f0f0f0', padding: '20px', borderRadius: '12px', boxShadow: '0 8px 16px rgba(0, 0, 0, 0.2)', textAlign: 'center', maxWidth: '90%', width: '300px', border: '1px solid #333' }); container.innerHTML = ` <p style="margin: 0 0 15px; font-size: 16px;">${message}</p> <button class="close-button" style="padding: 10px 20px; border-radius: 6px; border: none; background-color: #007bff; color: white; font-weight: bold; cursor: pointer; transition: all 0.2s ease;">閉じる</button> `; container.querySelector('.close-button').addEventListener('click', () => { document.body.removeChild(overlay); }); overlay.appendChild(container); document.body.appendChild(overlay); } /** * bsky.appで現在選択中のタブのパスを取得する関数 * @returns {string|null} tokimeki.blueで対応するパス名、または見つからない場合はnull */ function getActiveBskyTabPath() { const tabMappings = { 'メディア': '/media', 'Media': '/media', 'ビデオ': '/video', 'Videos': '/video', 'いいね': '/likes', 'Likes': '/likes', 'フィード': '/feed', 'Feeds': '/feed', 'リスト': '/lists', 'Lists': '/lists' }; // 選択中のタブを示す青い下線のstyle属性を直接探す const selectedIndicator = document.querySelector('[style*="background-color: rgb(16, 131, 254)"]'); if (selectedIndicator) { // 下線要素の親要素(タブ本体)を取得 const tabContentElement = selectedIndicator.parentElement; if (tabContentElement && tabContentElement.dataset.testid && tabContentElement.dataset.testid.startsWith('profilePager-')) { const testId = tabContentElement.getAttribute('data-testid'); const activeTabName = testId.replace('profilePager-', ''); if (tabMappings[activeTabName]) { console.log(`アクティブなbskyタブを検出: ${activeTabName}`); return tabMappings[activeTabName]; } } } console.log('アクティブなbskyタブは検出されませんでした(投稿タブなど)。'); return null; } // --- URL切り替え処理 --- function switchUrl() { const currentUrl = window.location.href; let newUrl = currentUrl; let bskyTabToClick = null; // bsky.app → tokimeki.blue if (currentUrl.includes('bsky.app')) { // プロフィールページの場合、アクティブなタブのパスを追加 if (currentUrl.includes('/profile/')) { const activeTabPath = getActiveBskyTabPath(); if (activeTabPath) { const urlObject = new URL(currentUrl); // 投稿、フィード、リストの詳細ページではないことを確認 if (!urlObject.pathname.match(/\/(post|feed|lists)\//)) { // クエリパラメータやハッシュを維持しつつ、パスの末尾に追加 newUrl = `${urlObject.origin}${urlObject.pathname.replace(/\/$/, '')}${activeTabPath}${urlObject.search}${urlObject.hash}`; } } } // 設定ページの場合、特定のURLに変換 if (currentUrl.includes('/settings')) { newUrl = 'https://tokimeki.blue/settings/general'; } // メッセージページの場合、特定のURLに変換 else if (currentUrl.includes('/messages')) { newUrl = 'https://tokimeki.blue/chat'; } // フォローリストのパスを変換 else if (currentUrl.endsWith('/follows')) { newUrl = newUrl.slice(0, -8) + '/follow'; } // フォロワーリストのパスを変換 else if (currentUrl.endsWith('/followers')) { newUrl = newUrl.slice(0, -10) + '/follower'; } // ドメインをtokimeki.blueに切り替え newUrl = newUrl.replace('bsky.app', 'tokimeki.blue'); // bskyのハッシュタグをtokimekiの検索クエリに変換 newUrl = newUrl.replace(/hashtag\/([^/]+)/, 'search?q=%23$1'); // bskyの?author=をtokimekiのfrom:に変換 newUrl = newUrl.replace(/\?author=([^&]+)/, ' from:$1'); // bsky.appで末尾がquotesの場合は削除 if (currentUrl.endsWith('quotes')) { newUrl = newUrl.slice(0, -7); } console.log("URLを切り替えます:", newUrl); window.location.href = newUrl; // tokimeki.blue → bsky.app } else if (currentUrl.includes('tokimeki.blue')) { // 設定ページの場合、特定のURLに変換 if (currentUrl.includes('/settings/')) { newUrl = 'https://bsky.app/settings'; } // チャットページの場合、特定のURLに変換 else if (currentUrl.includes('/chat')) { newUrl = 'https://bsky.app/messages'; } // tokimeki.blue独自のプロフィールページパスを検出 const profileMatch = currentUrl.match(/\/profile\/[^/]+\/(media|video|likes|feed|lists)$/); if (profileMatch) { const tab = profileMatch[1]; // bsky.appのプロフィールURLに変換 newUrl = newUrl.replace(/\/(media|video|likes|feed|lists)$/, ''); // タブを切り替えるフラグを設定 if (tab === 'media') bskyTabToClick = 'media'; else if (tab === 'video') bskyTabToClick = 'video'; else if (tab === 'likes') bskyTabToClick = 'likes'; else if (tab === 'feed') bskyTabToClick = 'feeds'; else if (tab === 'lists') bskyTabToClick = 'lists'; } // フォローリストのパスを変換 else if (currentUrl.endsWith('/follow')) { newUrl = newUrl.slice(0, -7) + '/follows'; } // フォロワーリストのパスを変換 else if (currentUrl.endsWith('/follower')) { newUrl = newUrl.slice(0, -9) + '/followers'; } // ドメインをbsky.appに切り替え newUrl = newUrl.replace('tokimeki.blue', 'bsky.app'); console.log("URLを切り替えます:", newUrl); // タブを切り替える必要があれば、URLにパラメータを付加 if (bskyTabToClick) { newUrl += '?switchTab=' + bskyTabToClick; } window.location.href = newUrl; } else { showMessage('bsky.app または tokimeki.blue ではありません。'); console.log("無効なURLです:", currentUrl); } } // ブラウザの言語設定を取得する function getBrowserLanguage() { const lang = navigator.language || navigator.userLanguage; return lang.startsWith('ja') ? 'ja' : 'en'; } // --- bsky.app側のページロード後の処理 --- function handleBskyPageLoad() { const urlParams = new URLSearchParams(window.location.search); const tabToClick = urlParams.get('switchTab'); if (tabToClick) { console.log(`BSkyのタブを切り替えます: ${tabToClick}`); const language = getBrowserLanguage(); let tabName = null; if (language === 'ja') { switch (tabToClick) { case 'media': tabName = 'メディア'; break; case 'video': tabName = 'ビデオ'; break; case 'likes': tabName = 'いいね'; break; case 'feeds': tabName = 'フィード'; break; case 'lists': tabName = 'リスト'; break; } } else { switch (tabToClick) { case 'media': tabName = 'Media'; break; case 'video': tabName = 'Videos'; break; case 'likes': tabName = 'Likes'; break; case 'feeds': tabName = 'Feeds'; break; case 'lists': tabName = 'Lists'; break; } } if (tabName) { const selector = `div[data-testid="profilePager-${tabName}"]`; const observer = new MutationObserver((mutations, obs) => { const tabElement = document.querySelector(selector); if (tabElement) { tabElement.click(); console.log(`タブをクリックしました: ${tabName}`); obs.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true }); } const newUrl = window.location.href.split('?')[0]; window.history.replaceState({}, document.title, newUrl); } } // --- 設定の読み込みと保存 --- function getConfig() { const defaultConfig = { global: { enabled: true, shortcutKey: 'Ctrl+Shift+K' }, bsky: { buttonPosition: 'bottom-left', buttonPadding: 60 }, tokimeki: { buttonPosition: 'bottom-left', buttonPadding: 60 } }; const savedConfig = GM_getValue(CONFIG_KEY); let currentConfig; try { currentConfig = savedConfig ? JSON.parse(savedConfig) : defaultConfig; if (!currentConfig.global) currentConfig.global = defaultConfig.global; if (!currentConfig.bsky) currentConfig.bsky = defaultConfig.bsky; if (!currentConfig.tokimeki) currentConfig.tokimeki = defaultConfig.tokimeki; } catch (e) { console.error("設定の読み込みに失敗しました。デフォルト設定を使用します。", e); currentConfig = defaultConfig; } const domain = getCurrentDomain(); return { ...currentConfig.global, ...(domain ? currentConfig[domain] : {}) }; } function saveConfig(config) { let savedData; try { const savedConfig = GM_getValue(CONFIG_KEY); savedData = savedConfig ? JSON.parse(savedConfig) : {}; } catch (e) { savedData = {}; } const domain = getCurrentDomain(); savedData.global = { enabled: config.enabled, shortcutKey: config.shortcutKey }; if (domain) { savedData[domain] = { buttonPosition: config.buttonPosition, buttonPadding: config.buttonPadding }; } GM_setValue(CONFIG_KEY, JSON.stringify(savedData)); } // --- ボタンのUI生成とイベントリスナー設定 --- function createButtonUI() { const existingButton = document.getElementById('bsky-tokimeki-switch-button'); if (existingButton) existingButton.remove(); const config = getConfig(); if (!config.enabled) return; const button = document.createElement('button'); button.id = 'bsky-tokimeki-switch-button'; Object.assign(button.style, { position: 'fixed', padding: '12px 18px', borderRadius: '9999px', border: 'none', backgroundColor: '#2563eb', color: 'white', fontSize: '16px', fontWeight: 'bold', cursor: 'pointer', boxShadow: '0 4px 14px rgba(0, 0, 0, 0.2)', zIndex: '99998', transition: 'all 0.3s ease-in-out', }); const domain = getCurrentDomain(); button.textContent = domain === 'bsky' ? '⇆' : (domain === 'tokimeki' ? '⇆' : '切り替え'); button.addEventListener('click', switchUrl); document.body.appendChild(button); updateButtonPosition(button, config.buttonPosition, config.buttonPadding); } // --- ボタン位置の更新関数 --- function updateButtonPosition(button, position, padding) { button.style.removeProperty('top'); button.style.removeProperty('bottom'); button.style.removeProperty('left'); button.style.removeProperty('right'); switch (position) { case 'top-left': Object.assign(button.style, { top: `${padding}px`, left: `${padding}px` }); break; case 'top-right': Object.assign(button.style, { top: `${padding}px`, right: `${padding}px` }); break; case 'bottom-left': Object.assign(button.style, { bottom: `${padding}px`, left: `${padding}px` }); break; case 'bottom-right': default: Object.assign(button.style, { bottom: `${padding}px`, right: `${padding}px` }); break; } } // --- キーボードショートカットのイベントハンドラ --- function handleKeyDown(e) { const config = getConfig(); const shortcut = config.shortcutKey; if (!shortcut) return; // 設定画面が開いてるときは無効化 if (document.querySelector('.bsky-settings-modal-overlay')) return; const keys = shortcut.split('+'); const isCtrl = keys.includes('Ctrl'); const isShift = keys.includes('Shift'); const isAlt = keys.includes('Alt'); const key = keys.pop().toLowerCase(); if (e.ctrlKey === isCtrl && e.shiftKey === isShift && e.altKey === isAlt && e.key.toLowerCase() === key) { const tag = (e.target && e.target.tagName) || ''; if (/(INPUT|TEXTAREA|SELECT)/.test(tag)) return; e.preventDefault(); switchUrl(); } } function showToast(msg) { const toast = document.createElement('div'); toast.textContent = msg; toast.style.cssText = ` position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--primary-color); color: white; padding: 10px 20px; border-radius: 6px; z-index: 100000; font-size: 14px; `; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2000); } // --- カスタム設定画面のUIとロジック --- function createSettingsUI() { const styles = ` :root { --bg-color: #1a1a1a; --text-color: #f0f0f0; --border-color: #333; --primary-color: #007bff; --primary-hover: #0056b3; --secondary-color: #343a40; --modal-bg: #212529; --shadow: 0 8px 16px rgba(0, 0, 0, 0.5); --border-radius: 12px; } .bsky-settings-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 100000; display: flex; justify-content: center; align-items: center; } .bsky-settings-modal { background-color: var(--modal-bg); color: var(--text-color); width: 90%; max-width: 400px; border-radius: var(--border-radius); box-shadow: var(--shadow); border: 1px solid var(--border-color); font-family: 'Inter', sans-serif; overflow: hidden; } .bsky-settings-header { padding: 15px 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } .bsky-settings-header h2 { margin: 0; font-size: 1.25rem; font-weight: 600; display: flex; align-items: center; gap: 10px; } .bsky-settings-header button { background: none; border: none; color: var(--text-color); font-size: 1.5rem; cursor: pointer; } .bsky-settings-body { padding: 20px; } .bsky-settings-section { background-color: #2c2c2c; border-radius: 8px; padding: 15px; margin-bottom: 20px; } .bsky-settings-section-title { font-size: 1.1rem; font-weight: bold; margin-top: 0; margin-bottom: 15px; color: #e0e0e0; border-bottom: 1px solid #444; padding-bottom: 5px; } .bsky-settings-option { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; } .bsky-settings-option:last-child { margin-bottom: 0; } .bsky-settings-option label { font-size: 1rem; font-weight: 500; } .bsky-settings-option select, .bsky-settings-option input[type="number"], .bsky-settings-option input[type="text"] { background-color: var(--secondary-color); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 6px; padding: 8px 12px; cursor: pointer; width: 100px; } .bsky-settings-option input[type="text"] { width: 150px; cursor: text; } .bsky-settings-option input:focus { border-color: var(--primary-color); box-shadow: 0 0 4px var(--primary-color); } .bsky-settings-footer { padding: 15px 20px; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } .bsky-settings-footer .version { font-size: 0.8rem; font-weight: 400; color: #aaa; } .bsky-settings-footer button { padding: 10px 20px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: all 0.2s ease; } .bsky-settings-footer .save-button { background-color: var(--primary-color); color: white; } .bsky-settings-footer .save-button:hover { background-color: var(--primary-hover); } .bsky-switch { position: relative; display: inline-block; width: 50px; height: 28px; } .bsky-switch input { opacity: 0; width: 0; height: 0; } .bsky-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 28px; } .bsky-slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .bsky-slider { background-color: var(--primary-color); } input:focus + .bsky-slider { box-shadow: 0 0 1px var(--primary-color); } input:checked + .bsky-slider:before { transform: translateX(22px); } `; const domain = getCurrentDomain(); const domainName = domain === 'bsky' ? 'bsky.app' : 'tokimeki.blue'; const html = ` <div class="bsky-settings-modal-overlay"> <div class="bsky-settings-modal"> <div class="bsky-settings-header"> <h2>設定</h2> <button class="close-modal-button">×</button> </div> <div class="bsky-settings-body"> <div class="bsky-settings-section"> <h3 class="bsky-settings-section-title">共通設定</h3> <div class="bsky-settings-option"> <label>ボタンを表示する</label> <label class="bsky-switch"> <input type="checkbox" id="bsky-enabled-toggle"> <span class="bsky-slider"></span> </label> </div> <div class="bsky-settings-option"> <label for="bsky-shortcut-key-input">ショートカットキー</label> <input type="text" id="bsky-shortcut-key-input" placeholder="キーを押して設定" value=""> </div> </div> <div class="bsky-settings-section"> <h3 class="bsky-settings-section-title">${domainName}</h3> <div class="bsky-settings-option"> <label for="bsky-button-position-select">ボタンの表示位置</label> <select id="bsky-button-position-select"> <option value="top-left">左上</option> <option value="top-right">右上</option> <option value="bottom-left">左下</option> <option value="bottom-right">右下</option> </select> </div> <div class="bsky-settings-option"> <label for="bsky-button-padding-input">ボタンの余白 (px)</label> <input type="number" id="bsky-button-padding-input" min="0" max="100" value="60"> </div> </div> </div> <div class="bsky-settings-footer"> <span class="version">(v${SCRIPT_VERSION})</span> <button class="save-button">保存</button> </div> </div> </div> `; if (!document.getElementById('bsky-settings-style')) { const styleSheet = document.createElement('style'); styleSheet.id = 'bsky-settings-style'; styleSheet.innerText = styles; document.head.appendChild(styleSheet); } const existingModal = document.querySelector('.bsky-settings-modal-overlay'); if (existingModal) existingModal.remove(); document.body.insertAdjacentHTML('beforeend', html); const currentConfig = getConfig(); const enabledToggle = document.getElementById('bsky-enabled-toggle'); const positionSelect = document.getElementById('bsky-button-position-select'); const paddingInput = document.getElementById('bsky-button-padding-input'); const shortcutInput = document.getElementById('bsky-shortcut-key-input'); shortcutInput.readOnly = true; // 入力欄は直接入力できないようにする enabledToggle.checked = currentConfig.enabled; positionSelect.value = currentConfig.buttonPosition; paddingInput.value = currentConfig.buttonPadding; shortcutInput.value = currentConfig.shortcutKey; const modalOverlay = document.querySelector('.bsky-settings-modal-overlay'); const closeModalButton = document.querySelector('.close-modal-button'); const saveButton = document.querySelector('.save-button'); closeModalButton.addEventListener('click', () => modalOverlay.remove()); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) modalOverlay.remove(); }); shortcutInput.addEventListener('keydown', (e) => { e.preventDefault(); // ESCキーは閉じる用にパス if (e.key === 'Escape') { modalOverlay.remove(); return; } const modifiers = []; if (e.ctrlKey) modifiers.push('Ctrl'); if (e.shiftKey) modifiers.push('Shift'); if (e.altKey) modifiers.push('Alt'); if (!['Control', 'Shift', 'Alt', 'Meta', 'Tab', 'Escape', 'Backspace', 'Delete'].includes(e.key)) { const key = e.key.length === 1 ? e.key.toUpperCase() : e.key; modifiers.push(key); } shortcutInput.value = modifiers.join('+'); }); saveButton.addEventListener('click', () => { const newConfig = { enabled: enabledToggle.checked, buttonPosition: positionSelect.value, buttonPadding: parseInt(paddingInput.value, 10) || 0, shortcutKey: shortcutInput.value }; saveConfig(newConfig); createButtonUI(); modalOverlay.remove(); showToast('設定を保存しました!'); }); // ESCキーで閉じる(任意のキーで消えないように once を使わない) const onEsc = (e) => { if (e.key === 'Escape') { modalOverlay.remove(); document.removeEventListener('keydown', onEsc); } }; document.addEventListener('keydown', onEsc); } // --- 初期化処理 --- function init() { createButtonUI(); document.removeEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown); if (getCurrentDomain() === 'bsky') { handleBskyPageLoad(); } if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand("URLを切り替える", switchUrl); GM_registerMenuCommand("設定", createSettingsUI); } } init(); })();