// ==UserScript== // @name IMDb to OpenSubtitles - Episode Exporter // @namespace https://github.com/MankeyDoodle // @version 1.0 // @description Extract TV show episode lists from IMDb with direct OpenSubtitles links. Export to RTF, HTML, CSV, JSON, or interactive checklist. Includes a Subtitles Launcher to batch-open subtitle pages. // @author MankeyDoodle // @license MIT // @match https://www.imdb.com/title/tt* // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect www.imdb.com // @homepageURL https://github.com/MankeyDoodle/IMDb-to-OpenSubtitles-Episode-Exporter // @supportURL https://github.com/MankeyDoodle/IMDb-to-OpenSubtitles-Episode-Exporter/issues // ==/UserScript== (function() { 'use strict'; // Icons: Phosphor Icons (https://phosphoricons.com) // License: MIT - Copyright (c) 2020 Phosphor Icons const ICONS = { clipboard: '', fileText: '', fileCsv: '', bracketsCurly: '', checkSquare: '', globe: '', spinner: '', check: '', xCircle: '', export: '', // Additional icons for launcher pause: '', play: '', stop: '', television: '', arrowClockwise: '' }; // Add styles GM_addStyle(` #imdb-extractor-container { position: fixed; top: 80px; right: 20px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } #imdb-extract-btn { padding: 12px 20px; background: #f5c518; color: #000; border: none; border-radius: 4px; font-weight: bold; font-size: 14px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: all 0.2s; } #imdb-extract-btn:hover { background: #e6b800; transform: scale(1.02); } #imdb-extract-btn:disabled { background: #888; cursor: wait; transform: none; } #imdb-extract-btn.success { background: #4caf50; color: white; } #imdb-extract-btn.ready { background: #2196f3; color: white; } #imdb-extract-btn.ready:hover { background: #1976d2; } #imdb-extract-btn.error { background: #f44336; color: white; } #imdb-export-menu { display: none; position: absolute; top: 100%; right: 0; margin-top: 8px; background: #1f1f1f; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); overflow: hidden; min-width: 200px; } #imdb-export-menu.show { display: block; } .imdb-export-option { display: block; width: 100%; padding: 12px 16px; background: none; border: none; color: #fff; font-size: 14px; text-align: left; cursor: pointer; transition: background 0.2s; } .imdb-export-option:hover { background: #333; } .imdb-export-option .icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; margin-right: 10px; vertical-align: middle; } .imdb-export-option .icon svg { width: 16px; height: 16px; } #imdb-extract-btn .btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; margin-right: 6px; vertical-align: middle; } #imdb-extract-btn .btn-icon svg { width: 16px; height: 16px; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } #imdb-extract-btn .btn-icon.spinning svg { animation: spin 1s linear infinite; } .imdb-menu-divider { height: 1px; background: #333; margin: 4px 0; } `); // Check if this is a TV show if (!checkIfTVShow()) return; // Create UI const container = document.createElement('div'); container.id = 'imdb-extractor-container'; const btn = document.createElement('button'); btn.id = 'imdb-extract-btn'; btn.innerHTML = `Extract Episodes`; const menu = document.createElement('div'); menu.id = 'imdb-export-menu'; menu.innerHTML = `
`; container.appendChild(btn); container.appendChild(menu); document.body.appendChild(container); // State let extractedData = null; let isExtracting = false; // Event listeners btn.addEventListener('click', async () => { if (isExtracting) return; if (!extractedData) { await extractAllEpisodes(); } if (extractedData) { menu.classList.toggle('show'); } }); document.addEventListener('click', (e) => { if (!container.contains(e.target)) { menu.classList.remove('show'); } }); menu.querySelectorAll('.imdb-export-option').forEach(option => { option.addEventListener('click', () => { const format = option.dataset.format; exportData(format); menu.classList.remove('show'); }); }); function checkIfTVShow() { const pageTitle = document.title || ''; const ogType = document.querySelector('meta[property="og:type"]')?.content || ''; const schemaType = document.querySelector('script[type="application/ld+json"]')?.textContent || ''; return pageTitle.includes('TV Series') || pageTitle.includes('TV Mini Series') || ogType.includes('tv_show') || schemaType.includes('TVSeries'); } function getShowInfo() { const showIdMatch = window.location.pathname.match(/\/title\/(tt\d+)/); const showId = showIdMatch ? showIdMatch[1] : ''; let showTitle = document.querySelector('[data-testid="hero__pageTitle"]')?.textContent?.trim() || document.title.replace(/ \(TV Series.*/, '').replace(/ \(TV Mini Series.*/, '') || 'Unknown Show'; return { showId, showTitle }; } async function fetchSeasonData(showId, season) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://www.imdb.com/title/${showId}/episodes/?season=${season}`, onload: function(response) { try { const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); const nextDataScript = doc.getElementById('__NEXT_DATA__'); let jsonData = null; if (nextDataScript) { jsonData = JSON.parse(nextDataScript.textContent); } const htmlEpisodes = []; const seenIds = new Set(); // Parse from HTML const titleElements = doc.querySelectorAll('.ipc-title__text'); titleElements.forEach(titleEl => { const text = titleEl.textContent?.trim() || ''; const match = text.match(/^S(\d+)\.E(\d+)\s*[∙·•\-]\s*(.+)$/i); if (!match) return; const parent = titleEl.closest('article, [class*="episode"]') || titleEl.parentElement?.parentElement?.parentElement; const link = parent?.querySelector('a[href*="/title/tt"]') || titleEl.closest('a[href*="/title/tt"]'); if (!link) return; const href = link.getAttribute('href') || ''; const idMatch = href.match(/\/title\/(tt\d+)/); if (!idMatch || seenIds.has(idMatch[1])) return; seenIds.add(idMatch[1]); htmlEpisodes.push({ id: idMatch[1], title: match[3].trim() || 'Unknown', seasonNumber: parseInt(match[1]), episodeNumber: parseInt(match[2]) }); }); resolve({ jsonData, htmlEpisodes, season, truncated: htmlEpisodes.length >= 50 }); } catch (e) { reject(e); } }, onerror: reject }); }); } function extractEpisodesFromData(fetchResult, seasonNum) { const { jsonData, htmlEpisodes } = fetchResult; const episodes = []; const seenIds = new Set(); // Try HTML parsing first (more reliable structure) if (htmlEpisodes && htmlEpisodes.length > 0) { htmlEpisodes.forEach(ep => { if (!seenIds.has(ep.id)) { seenIds.add(ep.id); episodes.push({ season: ep.seasonNumber || seasonNum, episode: ep.episodeNumber || 0, title: ep.title, imdbId: ep.id }); } }); // Check if HTML data has valid info (titles and episode numbers) const validCount = episodes.filter(ep => ep.title !== 'Unknown' && ep.episode > 0).length; if (validCount > 0) { return episodes; } } // Fall back to JSON if HTML didn't have good data if (jsonData) { const jsonEpisodes = []; const jsonSeenIds = new Set(); const pageProps = jsonData?.props?.pageProps; const episodesData = pageProps?.contentData?.section?.episodes?.items || pageProps?.mainColumnData?.episodes?.episodes?.edges || []; episodesData.forEach(item => { const ep = item.node || item; const epId = ep.id || ep.tconst; const epTitle = ep.titleText?.text || ep.originalTitleText?.text || ep.title || 'Unknown'; const epNum = ep.series?.episodeNumber?.episodeNumber || ep.episodeNumber || 0; const sNum = ep.series?.episodeNumber?.seasonNumber || ep.seasonNumber || seasonNum; if (epId && !jsonSeenIds.has(epId)) { jsonSeenIds.add(epId); jsonEpisodes.push({ season: parseInt(sNum) || seasonNum, episode: parseInt(epNum) || 0, title: epTitle, imdbId: epId }); } }); // Check if JSON has better data const jsonValidCount = jsonEpisodes.filter(ep => ep.title !== 'Unknown' && ep.episode > 0).length; if (jsonValidCount > episodes.filter(ep => ep.title !== 'Unknown' && ep.episode > 0).length) { return jsonEpisodes; } } return episodes; } async function extractAllEpisodes() { const { showId, showTitle } = getShowInfo(); if (!showId) { showError('Could not find show ID'); return; } isExtracting = true; btn.disabled = true; btn.innerHTML = `Fetching...`; try { // Fetch season 1 to get season list const season1Data = await fetchSeasonData(showId, 1); let seasonNumbers = []; if (season1Data) { const pageProps = season1Data.jsonData?.props?.pageProps; const seasons = pageProps?.contentData?.section?.seasons || pageProps?.mainColumnData?.episodes?.seasons || []; seasonNumbers = seasons .map(s => parseInt(s.value || s.number || s)) .filter(n => !isNaN(n) && n > 0) .sort((a, b) => a - b); } if (seasonNumbers.length === 0) seasonNumbers = [1]; const totalSeasons = seasonNumbers.length; const maxSeason = Math.max(...seasonNumbers); // Warning for shows with many seasons if (totalSeasons > 15) { const estimatedEpisodes = totalSeasons * 50; const warningMsg = `⚠️ This show has ${totalSeasons} seasons!\n\n` + `Due to IMDb limitations, only the first 50 episodes per season can be extracted.\n` + `Estimated: up to ${estimatedEpisodes} episodes.\n\n` + `Continue?`; if (!confirm(warningMsg)) { isExtracting = false; btn.disabled = false; btn.innerHTML = `Extract Episodes`; return; } } const allEpisodes = []; const startTime = Date.now(); let truncatedSeasons = 0; for (let i = 0; i < seasonNumbers.length; i++) { const seasonNum = seasonNumbers[i]; const episodeCount = allEpisodes.length > 0 ? ` | ${allEpisodes.length} eps` : ''; btn.innerHTML = `S${seasonNum}/${maxSeason}${episodeCount}`; try { const data = i === 0 ? season1Data : await fetchSeasonData(showId, seasonNum); if (data) { if (data.truncated) truncatedSeasons++; allEpisodes.push(...extractEpisodesFromData(data, seasonNum)); } } catch (e) { console.log(`Failed to fetch season ${seasonNum}:`, e); } if (i < seasonNumbers.length - 1) { await new Promise(r => setTimeout(r, 300)); } } if (allEpisodes.length === 0) { throw new Error('No episodes found'); } allEpisodes.sort((a, b) => { if (a.season !== b.season) return a.season - b.season; return a.episode - b.episode; }); // Add URLs allEpisodes.forEach(ep => { ep.imdbUrl = `https://www.imdb.com/title/${ep.imdbId}/`; ep.openSubtitlesUrl = `https://www.opensubtitles.org/en/search/imdbid-${ep.imdbId.replace('tt', '')}`; }); extractedData = { showTitle, showId, showUrl: `https://www.imdb.com/title/${showId}/`, totalSeasons, totalEpisodes: allEpisodes.length, episodes: allEpisodes }; btn.disabled = false; const elapsed = Math.round((Date.now() - startTime) / 1000); const timeText = elapsed > 10 ? ` (${elapsed}s)` : ''; btn.innerHTML = `${allEpisodes.length} episodes${timeText}`; btn.classList.add('success'); // Show truncation warning if any seasons had 50+ episodes if (truncatedSeasons > 0) { setTimeout(() => { alert(`Note: ${truncatedSeasons} season(s) may have been truncated.\n\n` + `IMDb only shows the first 50 episodes per season on their episode pages.\n\n` + `Extracted ${allEpisodes.length} episodes total.`); }, 100); } setTimeout(() => { btn.innerHTML = `${allEpisodes.length} Episodes Ready`; btn.classList.remove('success'); btn.classList.add('ready'); }, 2000); } catch (err) { showError(err.message); } isExtracting = false; } function showError(message) { btn.disabled = false; btn.innerHTML = `${message}`; btn.classList.add('error'); setTimeout(() => { btn.innerHTML = `Extract Episodes`; btn.classList.remove('error'); }, 3000); } function exportData(format) { if (!extractedData) return; const { showTitle, showId, totalSeasons, totalEpisodes, episodes } = extractedData; const safeTitle = showTitle.replace(/[^a-z0-9]/gi, '_'); switch (format) { case 'clipboard': copyToClipboard(); break; case 'rtf': downloadFile(generateRTF(), `${safeTitle}_episodes.rtf`, 'application/rtf'); break; case 'html': downloadFile(generateHTML(), `${safeTitle}_episodes.html`, 'text/html'); break; case 'csv': downloadFile(generateCSV(), `${safeTitle}_episodes.csv`, 'text/csv'); break; case 'json': downloadFile(JSON.stringify(extractedData, null, 2), `${safeTitle}_episodes.json`, 'application/json'); break; case 'checklist': downloadFile(generateChecklist(), `${safeTitle}_checklist.html`, 'text/html'); break; case 'open-subs': openAllSubtitles(); break; } } // Dark mode season colors for launcher const DARK_SEASON_COLORS = [ { bg: '#1a237e20', border: '#5c6bc0', header: '#7986cb' }, // Indigo { bg: '#4a148c20', border: '#ab47bc', header: '#ce93d8' }, // Purple { bg: '#1b5e2020', border: '#66bb6a', header: '#81c784' }, // Green { bg: '#e6510020', border: '#ff9800', header: '#ffb74d' }, // Orange { bg: '#88006420', border: '#f06292', header: '#f48fb1' }, // Pink { bg: '#00696320', border: '#4dd0e1', header: '#80deea' }, // Cyan { bg: '#ff6f0020', border: '#ffca28', header: '#ffd54f' }, // Amber { bg: '#33691e20', border: '#9ccc65', header: '#aed581' }, // Light Green { bg: '#311b9220', border: '#7e57c2', header: '#b39ddb' }, // Deep Purple { bg: '#b7180020', border: '#ef5350', header: '#e57373' }, // Red ]; function getDarkSeasonColor(seasonNum) { return DARK_SEASON_COLORS[(seasonNum - 1) % DARK_SEASON_COLORS.length]; } async function openAllSubtitles() { const { episodes, showTitle, showId } = extractedData; const total = episodes.length; // Get unique seasons for TOC const seasons = [...new Set(episodes.map(ep => ep.season))].sort((a, b) => a - b); // Build launcher HTML page const launcherHTML = `IMDb ID: ${showId}
`;
html += `Seasons: ${totalSeasons}
`;
html += `Episodes: ${totalEpisodes}
E${ep.episode} – ${ep.title}
`;
html += `IMDb (${ep.imdbId}) · Subtitles