// ==UserScript== // @name Joyn One Piece Userscript // @namespace https://joyn.de/ // @version 2.5.0 // @description High-End Erweiterung: Hybrid-Matching, Lazy-Loading, Staffel-Header, Smarte Filter (Refined Edition). // @match https://www.joyn.de/serien/one-piece* // @match https://www.joyn.de/play/serien/one-piece* // @match https://www.joyn.at/serien/one-piece* // @match https://www.joyn.at/play/serien/one-piece* // @match https://www.joyn.ch/serien/one-piece* // @match https://www.joyn.ch/play/serien/one-piece* // @icon https://www.google.com/s2/favicons?sz=64&domain=joyn.de // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.xmlHttpRequest // @connect de.wikipedia.org // @run-at document-idle // ==/UserScript== (() => { "use strict"; /******************************************************************** * 1. CONFIGURATION ********************************************************************/ const CFG = { WIKI_PAGE: "One_Piece_(Anime)/Episodenliste", API: "https://de.wikipedia.org/w/api.php", CACHE_KEY: "op_wiki_data_v250", PREF_KEY: "op_ui_prefs_v6", // Standard-Sortierung: 'overallDesc' (Neuste), 'overallAsc' (Älteste), 'joyn' (Original) DEFAULT_SORT: "overallDesc", // Validation Thresholds URL_ID_CONFIDENCE: 0.55, // Trust URL ID if title matches at least this much FUZZY_MIN_MATCH: 0.82, // Fallback text match requirement // UI Selectors (Joyn React Architecture) SEL_GRID: 'ul[data-testid="GRE"]', SEL_LINK: 'a[href*="/serien/one-piece"]', SEL_TITLE: '[data-testid="CRDTTL"]', // IDs TOOLBAR_ID: "op-toolbar-refined", STYLE_ID: "op-style-refined", STATUS_ID: "op-status-display" }; /******************************************************************** * 2. UTILITIES ********************************************************************/ const Utils = { async get(key, def) { try { if (typeof GM !== "undefined" && GM.getValue) return await GM.getValue(key) ?? def; } catch {} try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : def; } catch { return def; } }, async set(key, val) { try { if (typeof GM !== "undefined" && GM.setValue) { await GM.setValue(key, val); return; } } catch {} try { localStorage.setItem(key, JSON.stringify(val)); } catch {} }, fetch(url) { return new Promise((resolve, reject) => { if (typeof GM !== "undefined" && GM.xmlHttpRequest) { GM.xmlHttpRequest({ method: "GET", url, onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.responseText) : reject(`HTTP ${r.status}`), onerror: () => reject("Network Error") }); } else { fetch(url).then(r => r.ok ? r.text() : Promise.reject(r.status)).then(resolve).catch(reject); } }); }, norm(s) { return String(s || "") .normalize("NFKD").replace(/[\u0300-\u036f]/g, "") // Diacritics .toLowerCase() .replace(/[-–—!?:;.,]/g, " ") // Punctuation to space .replace(/[^a-z0-9 ]/g, "") // Keep alphanum + space .replace(/\s+/g, " ").trim(); // Collapse spaces }, tokens(s) { return this.norm(s).split(" ").filter(x => x.length >= 2); }, similarity(s1, s2) { const a = this.norm(s1), b = this.norm(s2); if (!a || !b) return 0; if (a === b) return 1; const bigrams = (str) => { const s = new Set(); for (let i = 0; i < str.length - 1; i++) s.add(str.slice(i, i + 2)); return s; }; const setA = bigrams(a), setB = bigrams(b); let intersection = 0; for (const item of setA) if (setB.has(item)) intersection++; return (2 * intersection) / (setA.size + setB.size); }, debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } }; /******************************************************************** * 3. WIKI DATA ENGINE ********************************************************************/ async function fetchWikiData(force = false) { const now = Date.now(); let cache = await Utils.get(CFG.CACHE_KEY, null); // 1. Use Cache if valid (24h) if (!force && cache && cache.episodes && (now - cache.ts < 86400000)) { return hydrateWiki(cache); } const api = (p) => `${CFG.API}?origin=*&format=json&formatversion=2&${new URLSearchParams(p)}`; // 2. Check Revision const revData = JSON.parse(await Utils.fetch(api({ action: "query", prop: "revisions", titles: CFG.WIKI_PAGE, rvprop: "ids", rvslots: "main" }))); const revid = revData?.query?.pages?.[0]?.revisions?.[0]?.revid; if (!force && cache && cache.revid === revid) { cache.ts = now; await Utils.set(CFG.CACHE_KEY, cache); return hydrateWiki(cache); } // 3. Download & Parse const parseData = JSON.parse(await Utils.fetch(api({ action: "parse", page: CFG.WIKI_PAGE, prop: "text", disabletoc: 1 }))); const html = parseData?.parse?.text; if (!html) throw new Error("Wiki HTML Missing"); const episodes = parseWikiHtml(html); const newCache = { revid, ts: now, episodes }; await Utils.set(CFG.CACHE_KEY, newCache); return hydrateWiki(newCache); } function parseWikiHtml(html) { const doc = new DOMParser().parseFromString(html, "text/html"); let section = "Unbekannt"; const eps = []; doc.querySelectorAll("h2, h3, h4, table.wikitable").forEach(node => { if (node.matches("h2, h3, h4")) { const t = node.textContent.replace(/\[.*?\]/g, "").trim(); if (t && !/inhalt/i.test(t)) section = t; } else { const rows = Array.from(node.querySelectorAll("tr")); if (rows.length < 2) return; // Auto-detect columns let idx = { ov: -1, de: -1, jp: -1 }; const headRow = rows.find(r => r.textContent.toLowerCase().includes("titel") && r.textContent.toLowerCase().includes("nr")); if(!headRow) return; Array.from(headRow.children).forEach((c, i) => { const t = c.textContent.toLowerCase(); if (t.includes("nr") && (t.includes("ges") || t === "nr.")) idx.ov = i; if (t.includes("titel") && (t.includes("deutsch") || t === "titel")) idx.de = i; if (t.includes("titel") && t.includes("original")) idx.jp = i; }); if (idx.ov === -1 || idx.de === -1) return; // Extract for (let r = rows.indexOf(headRow) + 1; r < rows.length; r++) { const cols = rows[r].children; if (cols.length <= Math.max(idx.ov, idx.de)) continue; const numVal = parseInt(cols[idx.ov].textContent.match(/\d+/) || [0]); const deVal = cols[idx.de].textContent.trim(); const jpVal = (idx.jp > -1 && cols[idx.jp]) ? cols[idx.jp].textContent.trim() : ""; if (numVal && deVal) { eps.push({ overall: numVal, titleDE: deVal, titleJP: jpVal, section }); } } } }); return eps; } function hydrateWiki(cache) { const byOverall = new Map(); const tokenIndex = new Map(); cache.episodes.forEach(ep => { byOverall.set(ep.overall, ep); Utils.tokens(ep.titleDE).forEach(t => { if(!tokenIndex.has(t)) tokenIndex.set(t, []); tokenIndex.get(t).push(ep.overall); }); }); return { ...cache, byOverall, tokenIndex }; } /******************************************************************** * 4. MATCHING ENGINE ********************************************************************/ function matchEpisode(anchor, joynTitle, wiki) { const url = anchor.getAttribute("href") || ""; // URL Regex: .../one-piece/2-1117-slug... const urlMatch = url.match(/\/one-piece\/\d+-(\d+)-/i); if (urlMatch) { const id = parseInt(urlMatch[1], 10); const ep = wiki.byOverall.get(id); if (ep) { // Sanity check title const sim = Utils.similarity(joynTitle, ep.titleDE); if (sim >= CFG.URL_ID_CONFIDENCE) return { ep, score: 1.0 }; } } // Fuzzy Fallback const jNorm = Utils.norm(joynTitle); const jTokens = Utils.tokens(joynTitle); const candidates = new Set(); jTokens.forEach(t => wiki.tokenIndex.get(t)?.forEach(id => candidates.add(id))); let best = null; const pool = (candidates.size > 0 && candidates.size < 50) ? candidates : wiki.episodes.map(e => e.overall); for (const id of pool) { const ep = wiki.byOverall.get(id); const sim = Utils.similarity(jNorm, ep.titleDE); if (!best || sim > best.score) best = { ep, score: sim }; } return (best && best.score >= CFG.FUZZY_MIN_MATCH) ? best : null; } /******************************************************************** * 5. UI: STYLES ********************************************************************/ function injectStyles() { if (document.getElementById(CFG.STYLE_ID)) return; const css = ` :root { --op-bg: rgba(16, 20, 26, 0.9); --op-border: rgba(255,255,255,0.08); --op-accent: #e0a42a; --op-text-muted: #aaa; } a[href*="/serien/one-piece"] picture[class*="Picture_Picture--fade-in"] { opacity: 1 !important; } #${CFG.TOOLBAR_ID} { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 12px 16px; margin: 0 0 16px 0; border-radius: 12px; background: var(--op-bg); backdrop-filter: blur(12px); position: sticky; top: 0; z-index: 50; border: 1px solid var(--op-border); } #${CFG.TOOLBAR_ID} * { box-sizing: border-box; font-family: sans-serif; font-size: 13px; } #${CFG.TOOLBAR_ID} input[type="search"] { flex: 1; min-width: 180px; height: 36px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--op-border); background: rgba(255,255,255,0.05); color: #fff; } #${CFG.TOOLBAR_ID} select { height: 36px; padding: 0 28px 0 12px; border-radius: 8px; border: 1px solid var(--op-border); background: rgba(255,255,255,0.05); color: #fff; cursor: pointer; appearance: none; } #${CFG.TOOLBAR_ID} label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: #ccc; padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.03); } #${CFG.TOOLBAR_ID} label:hover { background: rgba(255,255,255,0.08); color: #fff; } #${CFG.TOOLBAR_ID} button { height: 36px; padding: 0 16px; border-radius: 8px; border: none; background: var(--op-accent); color: #000; font-weight: bold; cursor: pointer; } #${CFG.TOOLBAR_ID} button:disabled { opacity: 0.5; cursor: default; } .op-season-header { width: 100%; grid-column: 1 / -1; margin: 32px 0 12px 0; padding-bottom: 8px; border-bottom: 2px solid rgba(224, 164, 42, 0.6); color: var(--op-accent); font-size: 20px; font-weight: 700; display: block !important; } .op-hidden { display: none !important; } .op-info-container { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--op-border); display: flex; flex-direction: column; gap: 4px; } .op-badges-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .op-badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 600; background: rgba(255,255,255,0.15); color: #eee; white-space: nowrap; flex-shrink: 0; } .op-badge-season { background: rgba(224, 164, 42, 0.15); color: var(--op-accent); border: 1px solid rgba(224, 164, 42, 0.3); max-width: 160px; overflow: hidden; text-overflow: ellipsis; } .op-score { margin-left: auto; font-size: 10px; font-weight: bold; opacity: 0.8; } .op-title-row { height: 18px; overflow: hidden; display: flex; align-items: center;} .op-title-text { font-size: 11px; color: #ddd; white-space: nowrap; } /* Marquee */ .op-marquee-wrap { overflow: hidden; position: relative; width: 100%; mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent); } .op-marquee-content { display: inline-block; white-space: nowrap; animation: op-scroll 12s 2s linear infinite alternate; padding-right: 20px; } @keyframes op-scroll { 0%, 15% { transform: translateX(0); } 85%, 100% { transform: translateX(-100%); } } .op-jp-box { background: rgba(0,0,0,0.3); border-radius: 4px; padding: 4px 6px; border-left: 2px solid #555; display: flex; flex-direction: column; } .op-jp-kanji { font-size: 12px; color: #fff; line-height: 1.3; } .op-jp-romaji { font-size: 10px; color: var(--op-text-muted); line-height: 1.2; margin-top: 1px; } `; const s = document.createElement("style"); s.id = CFG.STYLE_ID; s.textContent = css; document.head.appendChild(s); } /******************************************************************** * 6. STATE MANAGEMENT ********************************************************************/ const State = { wiki: null, prefs: { query: "", sort: CFG.DEFAULT_SORT, section: "", onlyMatch: false, headers: true }, observer: null }; /******************************************************************** * 7. RENDERING LOGIC ********************************************************************/ function renderCard(li, wikiEp, score) { const anchor = li.querySelector(CFG.SEL_LINK); if (!anchor) return; li.dataset.opId = wikiEp.overall; li.dataset.opSection = wikiEp.section; li.dataset.opTitle = wikiEp.titleDE; let info = anchor.querySelector('.op-info-container'); if (!info) { const meta = anchor.querySelector('[data-testid="MAT"]') || anchor.lastElementChild; info = document.createElement('div'); info.className = 'op-info-container'; if(meta && meta.parentNode) meta.parentNode.insertBefore(info, meta.nextSibling); else anchor.appendChild(info); } const color = score === 1.0 ? '#4caf50' : (score > 0.8 ? '#ff9800' : '#f44336'); const marquee = (text) => { if(text.length < 35) return `${text}`; return `
${text}
`; }; let jpHtml = ''; if(wikiEp.titleJP) { const m = wikiEp.titleJP.match(/^(.+?)\s*\((.+?)\)$/); jpHtml = `
${m ? m[1] : wikiEp.titleJP}
${m ? `
${m[2]}
` : ''}
`; } info.innerHTML = `
#${wikiEp.overall} ${wikiEp.section.replace(/Staffel\s+/i, 'S')} Match: ${Math.round(score*100)}%
${marquee(wikiEp.titleDE)}
${jpHtml} `; } function processGrid() { if (!State.wiki) return; const grid = document.querySelector(CFG.SEL_GRID); if (!grid) return; const lis = Array.from(grid.children).filter(el => el.matches("li")); // --- 1. MATCHING (Lazy) --- lis.forEach((li, idx) => { if (li.dataset.opProcessed) return; if (!li.dataset.opOriginalOrder) li.dataset.opOriginalOrder = idx; if (li.classList.contains("op-season-header") || li.querySelector('[class*="Placeholder"]')) return; const anchor = li.querySelector(CFG.SEL_LINK); const titleEl = li.querySelector(CFG.SEL_TITLE); if (anchor && titleEl) { const match = matchEpisode(anchor, titleEl.textContent, State.wiki); li.dataset.opProcessed = "1"; if (match) renderCard(li, match.ep, match.score); else li.dataset.opNoMatch = "1"; } }); // --- 2. PREPARE VISIBILITY --- grid.querySelectorAll('.op-season-header').forEach(h => h.remove()); const { query, section, onlyMatch, sort, headers } = State.prefs; const qNorm = Utils.norm(query); const visibleItems = []; lis.forEach(li => { if (li.classList.contains("op-season-header")) return; let visible = true; if (onlyMatch && li.dataset.opNoMatch) visible = false; if (visible && section && li.dataset.opSection !== section) visible = false; if (visible && qNorm) { const haystack = Utils.norm((li.dataset.opTitle || "") + " " + (li.textContent || "")); if (!haystack.includes(qNorm)) visible = false; } if (visible) { li.classList.remove("op-hidden"); visibleItems.push(li); } else { li.classList.add("op-hidden"); li.style.order = ""; } }); // --- 3. SORTING & GROUPING --- let effectiveSort = sort; // If headers enabled & original sort -> force Neuste (Desc) for logic consistency if (headers && !section && sort === 'joyn') { effectiveSort = 'overallDesc'; } if (effectiveSort !== 'joyn') { visibleItems.sort((a, b) => { const idA = parseInt(a.dataset.opId || 99999); const idB = parseInt(b.dataset.opId || 99999); if (effectiveSort === 'overallAsc') return idA - idB; if (effectiveSort === 'overallDesc') return idB - idA; return 0; }); } else { visibleItems.sort((a, b) => parseInt(a.dataset.opOriginalOrder) - parseInt(b.dataset.opOriginalOrder)); } // --- 4. INSERT HEADERS --- let orderCounter = 1; let lastSection = null; visibleItems.forEach(li => { if (headers && !section && (effectiveSort === 'overallAsc' || effectiveSort === 'overallDesc')) { const sec = li.dataset.opSection; if (sec && sec !== lastSection) { const h = document.createElement('li'); h.className = 'op-season-header'; h.textContent = sec; h.dataset.opProcessed = "1"; h.style.order = orderCounter++; grid.appendChild(h); lastSection = sec; } } li.style.order = orderCounter++; }); } /******************************************************************** * 8. UI INIT ********************************************************************/ function updateSectionDropdown() { const sel = document.querySelector(`#${CFG.TOOLBAR_ID} select.op-section`); if(!sel || !State.wiki) return; // Detect Available const grid = document.querySelector(CFG.SEL_GRID); const availableSections = new Set(); if(grid) { Array.from(grid.querySelectorAll('li.grid-item')).forEach(li => { if(li.dataset.opSection) availableSections.add(li.dataset.opSection); }); } const availableSorted = [...availableSections].sort((a,b) => { const nA = parseInt(a.match(/\d+/) || [0]); const nB = parseInt(b.match(/\d+/) || [0]); return nB - nA; // Descending (Newest first) }); const allWikiSections = new Set(); State.wiki.episodes.forEach(e => allWikiSections.add(e.section)); const otherSorted = [...allWikiSections] .filter(s => !availableSections.has(s)) .sort(); const current = sel.value; sel.innerHTML = ''; if(availableSorted.length > 0) { availableSorted.forEach(s => sel.add(new Option(`(Verfügbar) ${s}`, s))); sel.add(new Option("──────────", "")); } otherSorted.forEach(s => sel.add(new Option(s, s))); sel.value = current; } function renderToolbar() { if (document.getElementById(CFG.TOOLBAR_ID)) return; const panel = document.querySelector('.TabPanelContent_TabPanelContent__XtcG5') || document.querySelector('[role="tabpanel"]'); if (!panel) return; injectStyles(); const bar = document.createElement('div'); bar.id = CFG.TOOLBAR_ID; bar.innerHTML = ` `; const grid = document.querySelector(CFG.SEL_GRID); if(grid) grid.parentNode.insertBefore(bar, grid); else panel.prepend(bar); // Apply Saved State const els = { q: bar.querySelector('#op-search'), sort: bar.querySelector('#op-sort'), sec: bar.querySelector('#op-section'), chkMatch: bar.querySelector('#op-match'), chkHeader: bar.querySelector('#op-headers'), btn: bar.querySelector('#op-refresh') }; els.q.value = State.prefs.query; els.sort.value = State.prefs.sort; els.sec.value = State.prefs.section; els.chkMatch.checked = State.prefs.onlyMatch; els.chkHeader.checked = State.prefs.headers; const update = () => { State.prefs.query = els.q.value; State.prefs.sort = els.sort.value; State.prefs.section = els.sec.value; State.prefs.onlyMatch = els.chkMatch.checked; State.prefs.headers = els.chkHeader.checked; Utils.set(CFG.PREF_KEY, State.prefs); // Save processGrid(); }; els.q.addEventListener('input', Utils.debounce(update, 300)); [els.sort, els.sec, els.chkMatch, els.chkHeader].forEach(el => el.addEventListener('change', update)); els.btn.addEventListener('click', async () => { document.getElementById(CFG.STATUS_ID).textContent = "Lade..."; await init(true); }); } async function init(force = false) { try { const wiki = await fetchWikiData(force); State.wiki = wiki; document.getElementById(CFG.STATUS_ID).textContent = `Wiki OK (${wiki.episodes.length})`; processGrid(); updateSectionDropdown(); if (!State.observer) { const grid = document.querySelector(CFG.SEL_GRID); if (grid) { State.observer = new MutationObserver((mutations) => { const added = mutations.some(m => Array.from(m.addedNodes).some(n => n.nodeName === "LI")); if (added) setTimeout(() => { processGrid(); updateSectionDropdown(); }, 150); }); State.observer.observe(grid, { childList: true }); } } } catch (e) { console.error(e); document.getElementById(CFG.STATUS_ID).textContent = "Fehler"; } } async function boot() { const saved = await Utils.get(CFG.PREF_KEY); if (saved) State.prefs = { ...State.prefs, ...saved }; const loop = () => { if (document.getElementById("format-tabpanel-folge") && !document.getElementById(CFG.TOOLBAR_ID)) { renderToolbar(); init(); } }; setInterval(loop, 1000); window.addEventListener('popstate', () => setTimeout(loop, 500)); loop(); } boot(); })();