您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds more external links to Trakt.tv pages.
// ==UserScript== // @name External links on Trakt // @version 3.2.3 // @description Adds more external links to Trakt.tv pages. // @author Journey Over // @license MIT // @match *://trakt.tv/* // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/gm/gmcompat.js // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/wikidata/index.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/release/node-creation-observer-latest.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js // @grant GM.deleteValue // @grant GM.getValue // @grant GM.listValues // @grant GM.setValue // @grant GM.xmlHttpRequest // @run-at document-start // @inject-into content // @icon https://www.google.com/s2/favicons?sz=64&domain=trakt.tv // @homepageURL https://github.com/StylusThemes/Userscripts // @namespace https://greasyfork.org/users/32214 // ==/UserScript== /* global $, NodeCreationObserver, Wikidata */ (function() { 'use strict'; // ============================== // Constants and Configuration // ============================== const CONSTANTS = { CACHE_DURATION: 36e5, // 1 hour in milliseconds SCRIPT_ID: GM.info.script.name.toLowerCase().replace(/\s/g, '-'), CONFIG_KEY: 'external-trakt-links-config', TITLE: `${GM.info.script.name} Settings`, SCRIPT_NAME: GM.info.script.name, METADATA_SITES: [{ name: 'Rotten Tomatoes', desc: 'Provides a direct link to Rotten Tomatoes for the selected title.' }, { name: 'Metacritic', desc: 'Provides a direct link to Metacritic for the selected title.' }, { name: 'Letterboxd', desc: 'Provides a direct link to Letterboxd for the selected title.' }, { name: 'TVmaze', desc: 'Provides a direct link to TVmaze for the selected title.' }, { name: 'Mediux', desc: 'Provides a direct link to the Mediux Poster site for the selected title.' }, { name: 'MyAnimeList', desc: 'Provides a direct link to MyAnimeList for the selected title.' }, { name: 'AniDB', desc: 'Provides a direct link to AniDB for the selected title.' }, { name: 'AniList', desc: 'Provides a direct link to AniList for the selected title.' }, { name: 'Kitsu', desc: 'Provides a direct link to Kitsu for the selected title.' }, { name: 'AniSearch', desc: 'Provides a direct link to AniSearch for the selected title.' }, { name: 'LiveChart', desc: 'Provides a direct link to LiveChart for the selected title.' }, ], STREAMING_SITES: [{ name: 'BrocoFlix', desc: 'Provides a direct link to the BrocoFlix streaming page for the selected title.' }, { name: 'Cineby', desc: 'Provides a direct link to the Cineby streaming page for the selected title' }, { name: 'Moviemaze', desc: 'Provides a direct link to the Moviemaze streaming page for the selected title.' }, { name: 'P-Stream', desc: 'Provides a direct link to the P-Stream streaming page for the selected title.' }, { name: 'Rive', desc: 'Provides a direct link to the Rive streaming page for the selected title.' }, { name: 'Wovie', desc: 'Provides a direct link to the Wovie streaming page for the selected title.' }, { name: 'XPrime', desc: 'Provides a direct link to the XPrime streaming page for the selected title.' }, ], LINK_ORDER: [ 'Official Site', 'IMDb', 'TMDB', 'TVDB', 'Rotten Tomatoes', 'Metacritic', 'Letterboxd', 'TVmaze', 'MyAnimeList', 'AniDB', 'AniList', 'Kitsu', 'AniSearch', 'LiveChart', 'Fanart.tv', 'Mediux', 'BrocoFlix', 'Cineby', 'Moviemaze', 'P-Stream', 'Rive', 'Wovie', 'XPrime', 'JustWatch', 'Wikipedia', 'Twitter', 'Facebook', 'Instagram' ] }; // Default configuration values const DEFAULT_CONFIG = Object.fromEntries([ ['logging', false], ['debugging', false], ...CONSTANTS.METADATA_SITES.map(site => [site.name, true]), ...CONSTANTS.STREAMING_SITES.map(site => [site.name, true]) ]); // ====================== // Core Functionality // ====================== class TraktExternalLinks { constructor() { // Initialize with default configuration this.config = { ...DEFAULT_CONFIG }; this.wikidata = null; // Wikidata API instance this.mediaInfo = null; // Current media item metadata this.linkSettings = [ // All supported link settings ...CONSTANTS.METADATA_SITES, ...CONSTANTS.STREAMING_SITES ]; } // ====================== // Logging Methods // ====================== info(message, ...args) { if (this.config.logging) { console.info(`${CONSTANTS.SCRIPT_NAME}: INFO: ${message}`, ...args); } } warn(message, ...args) { if (this.config.logging) { console.warn(`${CONSTANTS.SCRIPT_NAME}: WARN: ${message}`, ...args); } } error(message, ...args) { if (this.config.logging) { console.error(`${CONSTANTS.SCRIPT_NAME}: ERROR: ${message}`, ...args); } } debug(message, ...args) { if (this.config.debugging) { console.debug(`${CONSTANTS.SCRIPT_NAME}: DEBUG: ${message}`, ...args); } } // ====================== // Initialization // ====================== async init() { // Main initialization sequence await this.loadConfig(); this.initializeWikidata(); this.logInitialization(); this.setupEventListeners(); } logInitialization() { const { version, author } = GM.info.script; const headerStyle = 'color:red;font-weight:bold;font-size:18px;'; const versionText = version ? `v${version} ` : ''; console.log( `%c${CONSTANTS.SCRIPT_NAME}\n%c${versionText}by ${author} is running!`, headerStyle, '' ); this.info('Script initialized'); this.debug('Debugging mode enabled'); this.debug('Current configuration:', this.config); } async loadConfig() { // Load saved configuration from storage const savedConfig = await GMC.getValue(CONSTANTS.CONFIG_KEY); if (savedConfig) { this.config = { ...DEFAULT_CONFIG, ...savedConfig }; } } initializeWikidata() { // Initialize Wikidata API with debugging option this.wikidata = new Wikidata({ debug: this.config.debugging }); } // ====================== // Event Handling // ====================== setupEventListeners() { // Watch for external links container and body element creation NodeCreationObserver.onCreation('.sidebar .external', () => this.handleExternalLinks()); NodeCreationObserver.onCreation('body', () => this.addSettingsMenu()); // Watch for collection links in list descriptions on collection pages NodeCreationObserver.onCreation('.text.readmore', () => this.handleCollectionLinks()); } // ====================== // Media Info // ====================== getMediaInfo() { // Extract media metadata from URL and DOM elements const pathParts = location.pathname.split('/'); const type = pathParts[1] === 'movies' ? 'movie' : 'tv'; // Safely get IDs from existing external links const imdbHref = $('#external-link-imdb').attr('href') || ''; const imdbId = imdbHref.match(/tt\d+/)?.[0] || null; const tmdbHref = $('#external-link-tmdb').attr('href') || ''; const tmdbMatch = tmdbHref.match(/\/(movie|tv)\/(\d+)/); const tmdbId = tmdbMatch ? tmdbMatch[2] : null; // Extract title from URL slug const slug = pathParts[2] || ''; const title = slug.split('-') .slice(1) // Remove any leading ID .join('-') .replace(/-\d{4}$/, ''); // Remove year suffix // Parse season/episode structure const seasonIndex = pathParts.indexOf('seasons'); const episodeIndex = pathParts.indexOf('episodes'); const season = seasonIndex > 0 ? +pathParts[seasonIndex + 1] : null; const episode = episodeIndex > 0 ? +pathParts[episodeIndex + 1] : null; return { type, imdbId, tmdbId, title, season: season || '1', episode: episode || '1', isSeasonPage: !!season && !episode }; } // ====================== // Link Management // ====================== async handleExternalLinks() { // Main link processing pipeline try { await this.clearExpiredCache(); this.mediaInfo = this.getMediaInfo(); if (this.mediaInfo.imdbId) { await this.processWikidataLinks(); } if (this.mediaInfo.tmdbId || this.mediaInfo.imdbId) { this.addCustomLinks(); } this.sortLinks(); } catch (error) { this.error(`Failed handling external links: ${error.message}`); } } sortLinks() { const container = $('.sidebar .external'); const listItem = container.find('li').first(); const links = listItem.children('a').detach(); const orderMap = new Map(CONSTANTS.LINK_ORDER.map((name, i) => [name.toLowerCase(), i])); const sorted = links.toArray().sort((a, b) => { const getKey = el => { const $el = $(el); // Check data-site first, then data-original-title, then text return $el.data('site') || $el.data('original-title') || $el.text().trim(); }; // Normalize the key for comparison const aKey = getKey(a).toLowerCase(); const bKey = getKey(b).toLowerCase(); return (orderMap.get(aKey) ?? Infinity) - (orderMap.get(bKey) ?? Infinity); }); listItem.append(sorted); } createLink(name, url) { // Create new external link element if it doesn't exist const id = `external-link-${name.toLowerCase().replace(/\s/g, '-')}`; if (!document.getElementById(id)) { $('.sidebar .external li').append( `<a target="_blank" id="${id}" href="${url}" data-original-title="" title="">${name}</a>` ); this.debug(`Added ${name} link: ${url}`); } } // ====================== // Wikidata Integration // ====================== async processWikidataLinks() { // Handle Wikidata links with caching const cache = await GMC.getValue(this.mediaInfo.imdbId); if (this.isCacheValid(cache)) { this.debug('Using cached Wikidata data'); this.addWikidataLinks(cache.links); return; } try { // Fetch fresh data from Wikidata API const data = await this.wikidata.links(this.mediaInfo.imdbId, 'IMDb', this.mediaInfo.type); await GMC.setValue(this.mediaInfo.imdbId, { links: data.links, item: data.item, time: Date.now() }); this.addWikidataLinks(data.links); this.debug('New Wikidata data fetched:', data.item); } catch (error) { this.error(`Failed fetching Wikidata links: ${error.message}`); } } addWikidataLinks(links) { // Add links from Wikidata data, filtering out anime sites for season pages const animeSites = new Set(['MyAnimeList', 'AniDB', 'AniList', 'Kitsu', 'AniSearch', 'LiveChart']); Object.entries(links).forEach(([site, link]) => { if ( site !== 'Trakt' && link?.value && this.config[site] !== false && !this.linkExists(site) && !(this.mediaInfo.isSeasonPage && animeSites.has(site)) ) { this.createLink(site, link.value); } }); } // ====================== // Custom Link Builders // ====================== addCustomLinks() { // Define custom link templates and conditions const customLinks = [{ name: 'Letterboxd', url: () => `https://letterboxd.com/tmdb/${this.mediaInfo.tmdbId}`, condition: () => this.mediaInfo.type === 'movie', requiredData: 'tmdbId' }, { name: 'Mediux', url: () => { const path = this.mediaInfo.type === 'movie' ? 'movies' : 'shows'; return `https://mediux.pro/${path}/${this.mediaInfo.tmdbId}`; }, condition: () => true, requiredData: 'tmdbId' }, { name: 'BrocoFlix', url: () => `https://brocoflix.com/pages/info?id=${this.mediaInfo.tmdbId}&type=${this.mediaInfo.type}`, condition: () => true, requiredData: 'tmdbId' }, { name: 'Cineby', url: () => { const show = this.mediaInfo.type === 'tv' ? `/${this.mediaInfo.season}/${this.mediaInfo.episode}` : ''; return `https://www.cineby.app/${this.mediaInfo.type}/${this.mediaInfo.tmdbId}${show}`; }, condition: () => true, requiredData: 'tmdbId' }, { name: 'Moviemaze', url: () => { const show = this.mediaInfo.type === 'tv' ? `?season=${this.mediaInfo.season}&ep=${this.mediaInfo.episode}` : ''; return `https://moviemaze.cc/watch/${this.mediaInfo.type}/${this.mediaInfo.tmdbId}${show}`; }, condition: () => true, requiredData: 'tmdbId' }, { name: 'P-Stream', url: () => { const show = this.mediaInfo.type === 'tv' ? `/${this.mediaInfo.season}/${this.mediaInfo.episode}` : ''; return `https://iframe.pstream.mov/embed/tmdb-${this.mediaInfo.type}-${this.mediaInfo.tmdbId}${show}`; }, condition: () => true, requiredData: 'tmdbId' }, { name: 'Rive', url: () => { const show = this.mediaInfo.type === 'tv' ? `&season=${this.mediaInfo.season}&episode=${this.mediaInfo.episode}` : ''; return `https://rivestream.org/watch?type=${this.mediaInfo.type}&id=${this.mediaInfo.tmdbId}${show}`; }, condition: () => true, requiredData: 'tmdbId' }, { name: 'Wovie', url: () => { const show = this.mediaInfo.type === 'tv' ? `?season=${this.mediaInfo.season}&episode=${this.mediaInfo.episode}` : ''; return `https://wovie.vercel.app/play/${this.mediaInfo.type}/${this.mediaInfo.tmdbId}/${this.mediaInfo.title}${show}`; }, condition: () => true, requiredData: 'tmdbId' }, { name: 'XPrime', url: () => { const show = this.mediaInfo.type === 'tv' ? `/${this.mediaInfo.season}/${this.mediaInfo.episode}` : ''; return `https://xprime.tv/watch/${this.mediaInfo.tmdbId}${show}`; }, condition: () => true, requiredData: 'tmdbId' } ]; customLinks.forEach(linkConfig => { if ( this.config[linkConfig.name] !== false && !this.linkExists(linkConfig.name) && this.mediaInfo[linkConfig.requiredData] && linkConfig.condition() ) { this.createLink(linkConfig.name, linkConfig.url()); } }); } // ====================== // Collection Link Handling // ====================== handleCollectionLinks() { if (!this.config.Mediux) return; const tmdbCollectionLinks = $('.text.readmore a[href*="themoviedb.org/collection/"]'); tmdbCollectionLinks.each((index, element) => { const $tmdbLink = $(element); const tmdbUrl = $tmdbLink.attr('href'); const collectionId = tmdbUrl.match(/collection\/(\d+)/)?.[1]; if (collectionId) { const mediuxUrl = `https://mediux.pro/collections/${collectionId}`; const mediuxLink = `<p><a href="${mediuxUrl}" target="_blank" class="comment-link">Mediux Collection</a></p>`; if (!$tmdbLink.next(`a[href="${mediuxUrl}"]`).length) { $tmdbLink.after(`${mediuxLink}`); } } }); } // ====================== // Cache Management // ====================== isCacheValid(cache) { return cache && !this.config.debugging && (Date.now() - cache.time) < CONSTANTS.CACHE_DURATION; } linkExists(site) { return $(`#external-link-${site.toLowerCase().replace(/\s/g, '-')}`).length > 0; } async clearExpiredCache() { // Clear expired cache entries const values = await GMC.listValues(); for (const value of values) { if (value === CONSTANTS.CONFIG_KEY) continue; const cache = await GMC.getValue(value); if (cache?.time && (Date.now() - cache.time) > CONSTANTS.CACHE_DURATION) { await GMC.deleteValue(value); } } } // ====================== // Settings UI // ====================== addSettingsMenu() { // Add settings menu item to user navigation const menuItem = `<li class="${CONSTANTS.SCRIPT_ID}"><a href="javascript:void(0)">EL Settings</a></li>`; $('div.user-wrapper ul.menu li.divider').last().after(menuItem); $(`.${CONSTANTS.SCRIPT_ID}`).click(() => this.openSettingsModal()); } openSettingsModal() { const modalHTML = this.generateSettingsModalHTML(); $(modalHTML).appendTo('body'); this.addModalStyles(); this.setupModalEventListeners(); } generateSettingsModalHTML() { const generateSection = (title, sites) => ` <div class="settings-section"> <h3><i class="fas fa-link"></i> ${title}</h3> <div class="link-settings-grid"> ${sites.map(site => { const id = site.name.toLowerCase().replace(/\s+/g, '_'); return ` <div class="setting-item"> <div class="setting-info"> <label for="${id}" title="${site.desc}">${site.name}</label> </div> <label class="switch"> <input type="checkbox" id="${id}" ${this.config[site.name] ? 'checked' : ''}> <span class="slider"></span> </label> </div> `; }).join('')} </div> </div> `; return ` <div id="${CONSTANTS.SCRIPT_ID}-config"> <div class="modal-content"> <div class="modal-header"> <h2>${CONSTANTS.TITLE}</h2> <button class="close-button">×</button> </div> <div class="settings-sections"> <div class="settings-section"> <h3><i class="fas fa-cog"></i> General Settings</h3> <div class="setting-item"> <div class="setting-info"> <label for="logging">Enable Logging</label> <div class="description">Show basic logs (info, warnings, errors) in console</div> </div> <label class="switch"> <input type="checkbox" id="logging" ${this.config.logging ? 'checked' : ''}> <span class="slider"></span> </label> </div> <div class="setting-item"> <div class="setting-info"> <label for="debugging">Enable Debugging</label> <div class="description">Show detailed debug information in console</div> </div> <label class="switch"> <input type="checkbox" id="debugging" ${this.config.debugging ? 'checked' : ''}> <span class="slider"></span> </label> </div> </div> ${generateSection('Metadata Sites', CONSTANTS.METADATA_SITES)} ${generateSection('Streaming Sites', CONSTANTS.STREAMING_SITES)} </div> <div class="modal-footer"> <button class="btn save" id="save-config">Save & Reload</button> <button class="btn warning" id="clear-cache">Clear Cache</button> </div> </div> </div> `; } addModalStyles() { const styles = `#${CONSTANTS.SCRIPT_ID}-config{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;justify-content:center;align-items:center}#${CONSTANTS.SCRIPT_ID}-config .modal-content{background:#2b2b2b;color:#fff;border-radius:8px;width:450px;max-width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3)}#${CONSTANTS.SCRIPT_ID}-config .modal-header{padding:1.5rem;border-bottom:1px solid #404040;position:relative;display:flex;justify-content:space-between;align-items:center}#${CONSTANTS.SCRIPT_ID}-config .modal-header h2{margin:0;font-size:1.4rem;color:#fff}#${CONSTANTS.SCRIPT_ID}-config .close-button{background:0;border:0;color:#fff;font-size:1.5rem;cursor:pointer;padding:0 .5rem}#${CONSTANTS.SCRIPT_ID}-config .settings-sections{padding:1.5rem;max-height:60vh;overflow-y:auto}#${CONSTANTS.SCRIPT_ID}-config .settings-section{margin-bottom:2rem}#${CONSTANTS.SCRIPT_ID}-config .settings-section h3{font-size:1.1rem;margin:0 0 1.2rem;color:#fff;display:flex;align-items:center;gap:.5rem}#${CONSTANTS.SCRIPT_ID}-config .setting-item{display:flex;justify-content:space-between;align-items:center;padding:.8rem 0;border-bottom:1px solid #404040}#${CONSTANTS.SCRIPT_ID}-config .setting-info{flex-grow:1;margin-right:1.5rem}#${CONSTANTS.SCRIPT_ID}-config .setting-info label{display:block;font-weight:500;margin-bottom:.3rem;cursor:help}#${CONSTANTS.SCRIPT_ID}-config .description{color:#a0a0a0;font-size:.9rem;line-height:1.4}#${CONSTANTS.SCRIPT_ID}-config .switch{flex-shrink:0}#${CONSTANTS.SCRIPT_ID}-config .modal-footer{padding:1.5rem;border-top:1px solid #404040;display:flex;gap:.8rem;justify-content:flex-end}#${CONSTANTS.SCRIPT_ID}-config .btn{padding:.6rem 1.2rem;border-radius:4px;border:0;cursor:pointer;font-weight:500;transition:all .2s ease}#${CONSTANTS.SCRIPT_ID}-config .btn.save{background:#4CAF50;color:#fff}#${CONSTANTS.SCRIPT_ID}-config .btn.warning{background:#f44336;color:#fff}#${CONSTANTS.SCRIPT_ID}-config .btn:hover{opacity:.9}#${CONSTANTS.SCRIPT_ID}-config .link-settings-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:.8rem}#${CONSTANTS.SCRIPT_ID}-config .link-settings-grid .setting-item{background:rgba(255,255,255,.05);border-radius:4px;padding:.8rem;border:1px solid #404040;margin:0}#${CONSTANTS.SCRIPT_ID}-config .link-settings-grid .setting-item:hover{background:rgba(255,255,255,.08)}`; $('<style>').prop('type', 'text/css').html(styles).appendTo('head'); } setupModalEventListeners() { $('.close-button').click(() => $(`#${CONSTANTS.SCRIPT_ID}-config`).remove()); $('#save-config').click(async () => { this.config.logging = $('#logging').is(':checked'); this.config.debugging = $('#debugging').is(':checked'); [...CONSTANTS.METADATA_SITES, ...CONSTANTS.STREAMING_SITES].forEach(site => { const checkboxId = site.name.toLowerCase().replace(/\s+/g, '_'); this.config[site.name] = $(`#${checkboxId}`).is(':checked'); }); await GMC.setValue(CONSTANTS.CONFIG_KEY, this.config); $(`#${CONSTANTS.SCRIPT_ID}-config`).remove(); window.location.reload(); }); $('#clear-cache').click(async () => { const values = await GMC.listValues(); for (const value of values) { if (value === CONSTANTS.CONFIG_KEY) continue; await GMC.deleteValue(value); } this.info('Cache cleared (excluding config)'); $(`#${CONSTANTS.SCRIPT_ID}-config`).remove(); window.location.reload(); }); } } // ====================== // Script Initialization // ====================== $(document).ready(async () => { // Start the main application const traktLinks = new TraktExternalLinks(); await traktLinks.init(); }); })();