// ==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 `