您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 Teachable 課程側邊欄為每個章節新增收藏按鈕,並提供篩選功能
// ==UserScript== // @name Teachable 課程章節收藏 // @namespace https://gist.github.com/Aya-X // @version 1.0.0 // @description 在 Teachable 課程側邊欄為每個章節新增收藏按鈕,並提供篩選功能 // @author Aya // @match *://*.teachable.com/courses/* // @match *://*.hexschool.com/courses/* // @grant GM_addStyle // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgOTYgOTYwIDk2MCIgd2lkdGg9IjQ4Ij48cGF0aCBkPSJNMjAwIDg5Ni4wNDNWMjI0LjY1Mmg1NjB2Njc1LjM5MWwtMjgwLTEyMC4wNDMtMjgwIDEyMC4wNDNabTg0Ljg3LTYxLjY1MyAxOTQuNDM1LTgzLjM0OCAxOTUuMTMtODIuMzVWMzEwLjg3SDI4NC44N1Y4MzQuMzlabTAtNTE5LjUyMmg0NzkuNTY1SDI4NC44N1Y4MzQuMzlWMzE0LjM5WiIvPjwvc3ZnPg== // @license MIT // ==/UserScript== /* eslint-env browser, greasemonkey */ (function () { 'use strict'; // === 事件發送器 === /** * @class EventEmitter * @description 事件發送器實作 */ class EventEmitter { constructor() { this.events = {}; } on(event, callback) { if (!this.events[event]) this.events[event] = []; this.events[event].push(callback); } emit(event, ...args) { if (!this.events[event]) return; this.events[event].forEach(callback => callback(...args)); } off(event, callback) { if (!this.events[event]) return; this.events[event] = this.events[event].filter(cb => cb !== callback); } } // === 設定用類別 === /** * @class Config * @description 儲存腳本所需的設定 */ class Config { static LESSON_ITEM_SELECTOR = 'li.section-item'; static LESSON_ID_ATTRIBUTE = 'data-lecture-id'; static STORAGE_KEY = 'teachable_favorite_lessons'; static BUTTON_CONTAINER_SELECTOR = '.lecture-left'; static COURSE_SECTION_SELECTOR = '.course-section'; static COURSE_SIDEBAR_SELECTOR = '.course-sidebar'; static FILTER_CLASS_NAME = 'favorite-filter-active'; static ICONS = { OUTLINED: `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#555555"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2zm0 15l-5-2.18L7 18V5h10v13z"/></svg>`, FILLED: `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#FB8C00"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/></svg>`, FILTER: `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>` }; static STYLES = ` .teachable-course ${Config.LESSON_ITEM_SELECTOR} a.item { position: relative; display: flex; align-items: center; width: 100%; } .favorite-btn-container { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); z-index: 10; } .favorite-btn { background: none; border: none; cursor: pointer; padding: 4px; border-radius: 50%; display: inline-flex; justify-content: center; align-items: center; transition: background-color 0.2s; } .favorite-btn:hover { background-color: rgba(0, 0, 0, 0.08); } .teachable-course ${Config.LESSON_ITEM_SELECTOR} .title-container { padding-right: 35px; } .favorite-filter-icon { display: inline-block; vertical-align: middle; line-height: 1; cursor: pointer; padding: 0 4px; } .favorite-filter-icon svg { display: block; fill: #ffffff; transition: fill 0.2s; } .favorite-filter-icon:hover svg { fill: #555555; } .favorite-filter-icon.active svg { fill: #FB8C00; } .${Config.FILTER_CLASS_NAME} ${Config.LESSON_ITEM_SELECTOR}:not(.is-favorite), .${Config.FILTER_CLASS_NAME} ${Config.COURSE_SECTION_SELECTOR}:not(.has-favorite) { display: none; } `; } // === 工具 === /** * @class Utils * @description 提供通用的工具函式 */ class Utils { /** * 建立一個 Debounce 函式,該函式會延遲執行 func 函式,直到使用者停止觸發事件後的一段時間 * @param {Function} func - 要進行 Debounce 的函式 * @param {number} wait - 延遲的毫秒數 * @returns {Function} 回傳新的 Debounce 化的函式 */ static debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } } // === 資料管理類別 === /** * @class FavoriteStorage * @description 處理收藏章節資料的載入、儲存和管理,使用 localStorage */ class FavoriteStorage extends EventEmitter { /** * @param {string} [storageKey=Config.STORAGE_KEY] - 用於 localStorage 的鍵名 */ constructor(storageKey = Config.STORAGE_KEY) { super(); this.storageKey = storageKey; this.favorites = new Set(); this.load(); } /** * 從 localStorage 載入收藏的章節 ID * @private */ load() { try { const stored = localStorage.getItem(this.storageKey); if (stored) { const parsedData = JSON.parse(stored); this.favorites = new Set(Array.isArray(parsedData) ? parsedData : []); } } catch (error) { console.warn('載入收藏資料失敗:', error); this.favorites = new Set(); } } /** * 將目前的收藏章節 ID 儲存到 localStorage * @private */ save() { try { localStorage.setItem(this.storageKey, JSON.stringify(Array.from(this.favorites))); } catch (error) { console.error('儲存收藏資料失敗:', error); } } /** * 新增一個收藏章節 * @param {string} lessonId - 章節 ID */ add(lessonId) { this.favorites.add(lessonId); this.save(); this.emit('change', { lessonId, action: 'add' }); } /** * 移除一個收藏章節。 * @param {string} lessonId - 章節 ID */ remove(lessonId) { this.favorites.delete(lessonId); this.save(); this.emit('change', { lessonId, action: 'remove' }); } /** * 檢查某個章節是否已被收藏 * @param {string} lessonId - 章節 ID * @returns {boolean} 如果已收藏則回傳 true,否則 false */ has(lessonId) { return this.favorites.has(lessonId); } /** * 切換一個章節的收藏狀態 * @param {string} lessonId - 章節 ID * @returns {boolean} 如果切換後為收藏狀態,回傳 true,否則 false */ toggle(lessonId) { if (this.has(lessonId)) { this.remove(lessonId); return false; } else { this.add(lessonId); return true; } } /** * 取得所有收藏章節的 ID 列表 * @returns {string[]} 收藏章節 ID 的陣列 */ getAll() { return Array.from(this.favorites); } /** * 取得收藏章節的總數 * @returns {number} 收藏章節的數量 */ getCount() { return this.favorites.size; } /** * 清除所有收藏 */ clear() { this.favorites.clear(); this.save(); this.emit('clear'); } } // === UI 類別 === /** * @class FavoriteButton * @description 代表一個章節旁的收藏按鈕 UI 元件 */ class FavoriteButton extends EventEmitter { /** * @param {string} lessonId - 此按鈕對應的章節 ID * @param {FavoriteStorage} storage - 用於儲存狀態的 storage */ constructor(lessonId, storage) { super(); this.lessonId = lessonId; this.storage = storage; this.element = this.createElement(); this.button = this.element.querySelector('.favorite-btn'); this.updateState(); } /** * 建立按鈕的 DOM 結構 * @returns {HTMLDivElement} 包含按鈕的容器元素 * @private */ createElement() { const container = document.createElement('div'); container.className = 'favorite-btn-container'; const button = document.createElement('button'); button.className = 'favorite-btn'; button.addEventListener('click', this.handleClick.bind(this)); container.appendChild(button); return container; } /** * 處理按鈕點擊事件 * @param {MouseEvent} event - 點擊事件物件 * @private */ handleClick(event) { event.preventDefault(); event.stopPropagation(); const newState = this.storage.toggle(this.lessonId); this.updateState(); this.emit('stateChanged', { lessonId: this.lessonId, isFavorited: newState }); } /** * 根據收藏狀態更新按鈕的圖示、標題和 aria 屬性 */ updateState() { const isFavorited = this.storage.has(this.lessonId); this.button.innerHTML = isFavorited ? Config.ICONS.FILLED : Config.ICONS.OUTLINED; this.button.setAttribute('title', isFavorited ? '取消收藏' : '收藏此章節'); this.button.setAttribute('aria-pressed', isFavorited.toString()); } /** * 將按鈕附加到指定的元素 * @param {HTMLElement} parentElement - 要附加到的元素 */ appendTo(parentElement) { parentElement.appendChild(this.element); } /** * 從 DOM 中移除此按鈕 */ remove() { if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } } /** * 檢查此按鈕對應的章節是否已收藏 * @returns {boolean} 是否已收藏 */ isFavorited() { return this.storage.has(this.lessonId); } /** * 檢查按鈕元素是否仍在 DOM 中 * @returns {boolean} 是否已連接到 DOM */ isConnected() { return this.element && this.element.isConnected; } } // === 主要管理類別 === /** * @class TeachableFavoriteManager * @description 腳本的主要控制器,管理收藏按鈕、篩選器和 DOM 監聽 */ class TeachableFavoriteManager extends EventEmitter { constructor() { super(); this.storage = new FavoriteStorage(); this.observer = null; this.lessonButtons = new Map(); this.isFilterActive = false; this.filterButton = null; // 快取相關 this._lessonItemsCache = null; this._cacheTimeout = null; this._observerTarget = null; this.debouncedProcess = Utils.debounce(() => { this.processExistingLessons(); this.createFilterButton(); if (this.isFilterActive) { this.applyFavoriteFilter(); } }, 300); // 監聽 storage 事件 this.storage.on('change', () => { if (this.isFilterActive) { this.applyFavoriteFilter(); } }); this.init(); } /** * 初始化腳本,注入樣式、設定監聽器並處理現有章節 * @private */ init() { document.body.classList.add('teachable-course'); this.injectStyles(); this.createFilterButton(); this.setupObserver(); this.processExistingLessons(); } /** * 注入腳本所需的 CSS 樣式 * @private */ injectStyles() { GM_addStyle(Config.STYLES); } /** * 取得章節項目,支援快取 * @param {boolean} forceRefresh - 是否強制重新查詢 * @returns {NodeList} */ getLessonItems(forceRefresh = false) { if (!forceRefresh && this._lessonItemsCache) { return this._lessonItemsCache; } this._lessonItemsCache = document.querySelectorAll(Config.LESSON_ITEM_SELECTOR); // 5秒後清除快取 clearTimeout(this._cacheTimeout); this._cacheTimeout = setTimeout(() => { this._lessonItemsCache = null; }, 5000); return this._lessonItemsCache; } /** * 設定 MutationObserver 來監聽課程列表的 DOM 變動,以動態新增或移除按鈕 * @private */ setupObserver() { // 優先找到更具體的容器 this._observerTarget = document.querySelector(Config.COURSE_SIDEBAR_SELECTOR) || document.querySelector('.course-content') || document.body; const config = { childList: true, subtree: true, attributeFilter: ['data-lecture-id'] // 只監聽特定屬性的變化 }; this.observer = new MutationObserver((mutations) => { // 過濾相關的變動 const relevantMutation = mutations.some(mutation => { // 檢查是否有相關的節點變動 if (mutation.type === 'childList') { // 檢查新增或移除的節點是否包含課程項目 return [...mutation.addedNodes, ...mutation.removedNodes].some(node => { if (node.nodeType !== Node.ELEMENT_NODE) return false; return node.matches && ( node.matches(Config.LESSON_ITEM_SELECTOR) || (node.querySelector && node.querySelector(Config.LESSON_ITEM_SELECTOR)) ); }); } return false; }); if (!relevantMutation) return; // 清除快取,因為 DOM 已改變 this._lessonItemsCache = null; if (!document.querySelector(Config.BUTTON_CONTAINER_SELECTOR)) return; this.debouncedProcess(); }); this.observer.observe(this._observerTarget, config); } /** * 處理頁面上所有已存在的章節,為它們新增收藏按鈕 */ processExistingLessons() { const lessonItems = this.getLessonItems(); this.cleanupDisconnectedButtons(); lessonItems.forEach(item => { const lessonId = item.getAttribute(Config.LESSON_ID_ATTRIBUTE); if (!lessonId) return; // 如果按鈕已存在且仍在 DOM 中,則跳過 if (this.lessonButtons.has(lessonId) && this.lessonButtons.get(lessonId).button.isConnected()) { return; } const linkElement = item.querySelector('a.item'); if (linkElement && linkElement.isConnected) { this.addFavoriteButton(item, linkElement, lessonId); } }); // 更新收藏狀態的 CSS 類別 this.updateFavoriteClasses(); } /** * 更新所有項目的收藏狀態 CSS 類別 * @private */ updateFavoriteClasses() { this.lessonButtons.forEach(({ item }, lessonId) => { item.classList.toggle('is-favorite', this.storage.has(lessonId)); }); // 更新區塊的收藏狀態 const sections = document.querySelectorAll(Config.COURSE_SECTION_SELECTOR); sections.forEach(section => { const hasFavorite = section.querySelector('.is-favorite'); section.classList.toggle('has-favorite', !!hasFavorite); }); } /** * 為單一章節新增收藏按鈕。 * @param {HTMLLIElement} item - 章節的 li 元素 * @param {HTMLAnchorElement} linkElement - 章節的 a 連結元素 * @param {string} lessonId - 章節 ID * @private */ addFavoriteButton(item, linkElement, lessonId) { const favoriteButton = new FavoriteButton(lessonId, this.storage); // 監聽按鈕狀態改變事件 favoriteButton.on('stateChanged', ({ lessonId, isFavorited }) => { item.classList.toggle('is-favorite', isFavorited); // 更新所屬區塊的狀態 const section = item.closest(Config.COURSE_SECTION_SELECTOR); if (section) { const hasFavorite = section.querySelector('.is-favorite'); section.classList.toggle('has-favorite', !!hasFavorite); } // 發送事件 this.emit('favoriteChanged', { lessonId, isFavorited }); }); favoriteButton.appendTo(linkElement); const titleContainer = item.querySelector('.title-container'); if (titleContainer) { titleContainer.style.paddingRight = '35px'; } this.lessonButtons.set(lessonId, { item, button: favoriteButton }); } /** * 清理已從 DOM 中移除的章節所對應的按鈕 * @private */ cleanupDisconnectedButtons() { for (const [lessonId, { item, button }] of this.lessonButtons.entries()) { if (!item.isConnected || !button.isConnected()) { button.remove(); this.lessonButtons.delete(lessonId); } } } /** * 在課程側邊欄頂部建立「篩選收藏」按鈕 */ createFilterButton() { const container = document.querySelector(Config.BUTTON_CONTAINER_SELECTOR); if (container && !document.getElementById('favorite-filter-btn')) { const a = document.createElement('a'); a.id = 'favorite-filter-btn'; a.className = 'nav-icon-settings favorite-filter-icon'; a.href = '#'; a.setAttribute('role', 'button'); a.title = '顯示已收藏章節'; a.setAttribute('aria-label', '顯示已收藏章節'); a.innerHTML = Config.ICONS.FILTER; a.addEventListener('click', (event) => { event.preventDefault(); this.toggleFavoriteFilter(); }); const settingsDropdown = container.querySelector('.settings-dropdown'); if (settingsDropdown) { container.insertBefore(a, settingsDropdown); } else { container.appendChild(a); } this.filterButton = a; } } /** * 切換收藏篩選器的啟用狀態 */ toggleFavoriteFilter() { this.isFilterActive = !this.isFilterActive; if (this.isFilterActive) { this.applyFavoriteFilter(); this.filterButton.title = '顯示所有章節'; this.filterButton.setAttribute('aria-label', '顯示所有章節'); this.filterButton.classList.add('active'); } else { this.clearFavoriteFilter(); this.filterButton.title = '顯示已收藏章節'; this.filterButton.setAttribute('aria-label', '顯示已收藏章節'); this.filterButton.classList.remove('active'); } this.emit('filterToggled', { isActive: this.isFilterActive }); } /** * 套用收藏篩選,使用 CSS 類別控制顯示 */ applyFavoriteFilter() { // 先確保所有收藏狀態的類別都是最新的 this.updateFavoriteClasses(); // 只需要新增一個類別到 body,CSS 會處理其餘的顯示邏輯 document.body.classList.add(Config.FILTER_CLASS_NAME); } /** * 清除收藏篩選 */ clearFavoriteFilter() { document.body.classList.remove(Config.FILTER_CLASS_NAME); } /** * 更新指定章節 ID 的按鈕狀態 * @param {string} lessonId - 要更新按鈕的章節 ID */ updateButtonState(lessonId) { const buttonInfo = this.lessonButtons.get(lessonId); if (buttonInfo && buttonInfo.button.isConnected()) { buttonInfo.button.updateState(); } } /** * 更新所有收藏按鈕的狀態 */ updateAllButtons() { this.lessonButtons.forEach(({ button }) => { if (button.isConnected()) button.updateState(); }); this.updateFavoriteClasses(); } /** * 移除所有監聽器、按鈕和樣式 */ destroy() { if (this.observer) { this.observer.disconnect(); this.observer = null; } this.lessonButtons.forEach(({ button }) => button.remove()); this.lessonButtons.clear(); if (this.filterButton) { this.filterButton.remove(); this.filterButton = null; } clearTimeout(this._cacheTimeout); this._lessonItemsCache = null; } /** * 重新執行一次初始化 */ refresh() { this.destroy(); this.init(); } // --- Public API methods --- getFavorites() { return this.storage.getAll(); } addFavorite(lessonId) { this.storage.add(lessonId); this.updateButtonState(lessonId); this.updateFavoriteClasses(); } removeFavorite(lessonId) { this.storage.remove(lessonId); this.updateButtonState(lessonId); this.updateFavoriteClasses(); } isFavorite(lessonId) { return this.storage.has(lessonId); } getFavoriteCount() { return this.storage.getCount(); } clearAllFavorites() { this.storage.clear(); this.updateAllButtons(); } } // === 腳本啟動 === let favoriteManager = null; /** * 初始化應用程式,建立 TeachableFavoriteManager */ function initializeApp() { try { if (!window.TeachableFavorites || !window.TeachableFavorites.getManager()) { favoriteManager = new TeachableFavoriteManager(); console.log('Teachable 收藏功能已啟動 (優化版)'); } } catch (error) { console.error('Teachable 收藏功能啟動失敗:', error); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeApp); } else { initializeApp(); } /** * @namespace TeachableFavorites * @description 將收藏功能 API 掛到 window 物件,以便從瀏覽器主控台進行操作 */ window.TeachableFavorites = { getFavorites: () => favoriteManager?.getFavorites() || [], addFavorite: (lessonId) => favoriteManager?.addFavorite(lessonId), removeFavorite: (lessonId) => favoriteManager?.removeFavorite(lessonId), isFavorite: (lessonId) => favoriteManager?.isFavorite(lessonId) || false, getCount: () => favoriteManager?.getFavoriteCount() || 0, clearAll: () => favoriteManager?.clearAllFavorites(), refresh: () => favoriteManager?.refresh(), getManager: () => favoriteManager, on: (event, callback) => favoriteManager?.on(event, callback), off: (event, callback) => favoriteManager?.off(event, callback) }; })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址