JAVDB/JAVBUS/JAVLIBRARY to Jable.tv Linker (Class Refactor)

Add Jable.tv link button to JAVDB, JAVBUS, JAVLIBRARY, AV01 and AVJOY pages, and cross-site navigation buttons, plus subtitle site links. Refactored as JableLinker class for maintainability.

// ==UserScript==
// @name        JAVDB/JAVBUS/JAVLIBRARY to Jable.tv Linker (Class Refactor)
// @namespace   https://tampermonkey.net/
// @version     2025.09.29
// @description Add Jable.tv link button to JAVDB, JAVBUS, JAVLIBRARY, AV01 and AVJOY pages, and cross-site navigation buttons, plus subtitle site links. Refactored as JableLinker class for maintainability.
// @author      庄引X@https://x.com/zhuangyin8
// @match       https://javdb.com/*
// @match       https://www.javbus.com/*
// @match       https://www.javlibrary.com/*
// @match       https://jable.tv/*
// @match       https://avjoy.me/video/*
// @match       https://www.av01.tv/*
// @match       https://91md.me/*
// @match       https://missav.ws/*
// @match       https://*.btdig.com/*
// @match       https://sukebei.nyaa.si/*
// @match       https://btdig.com/*
// @match       https://en.btdig.com/*
// @match       https://btsow.pics/*
// @grant       GM_addStyle
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @run-at      document-end
// @connect     jable.tv
// @connect     javdb.com
// @connect     javbus.com
// @connect     avjoy.me
// @connect     av01.tv
// @license     MIT
// ==/UserScript==

class JableLinker {
    constructor() {
        this.SITES = {
            JAVDB: "javdb.com",
            JAVBUS: "javbus.com",
            JAVLIBRARY: "javlibrary.com",
            JABLE: "jable.tv",
            AVJOY: "avjoy.me",
            AV01: "av01.tv",
            MDME: "91md.me",
            MISSAV: "missav.ws",
            SUKEBEI: "sukebei.nyaa.si",
            BTDIG: "btdig.com",
            BTSOW: "btsow.pics"
        };

        this.SELECTORS = {
            // JAV Site Selectors
            JAVDB_CODE: ".value", JAVDB_TITLE: ".title", JAVDB_PREVIEW_IMAGES: ".preview-images .tile-item", JAVBUS_SAMPLE_BOX: ".sample-box", JABLE_TITLE: "h4", JAVBUS_TITLE: ".container h3", JAVLIBRARY_TITLE: "h3.text", AVJOY_TITLE: ".video-title h1", AV01_TITLE: "h1", MISSAV_TITLE: ".mt-4 h1",
        };

        this.STYLES = {
            SUKEBEI: `.container .ad,.hdr-link,tr td:nth-child(1),tr td:nth-child(3),tr td:nth-child(6),tr td:nth-child(7),.text-center{display:none !important;} .table {width: 100%;} .torrent-list>tbody>tr>td { white-space: normal; max-width:90vw;} .navbar-form .input-group {position: fixed; left:0;width: 100vw;}`,
            BTDIG: ``,
            BTSOW: `.search {position: sticky !important;top: 80px !important;} .form-inline .input-group {width: 100%;} .hidden-xs:not(.tags-box,.text-right,.search,.search-container),.data-list:not(.detail) .size,.data-list:not(.detail) .date{ display: none !important;}`,
            JAVBUS: `.row > #waterfall, .container { width: 100vw !important; } .masonry #waterfall,.mb20 { display: grid; grid-template-columns: repeat(3, 1fr); } .movie-box { width: auto !important; height: 100% !important; margin: 0 !important; } #waterfall .masonry-brick { position: relative !important; top: 0px !important; left: 0 !important; } .screencap img {width:auto  !important; }.movie-box .photo-frame, .movie-box img { width: 100% !important; height: auto !important; margin: 0 !important;} iframe,.banner728,.banner300,.bcpic2{display:none}`,
            JAVDB: `.container:not(.is-max-desktop):not(.is-max-widescreen) { max-width: 100vw; } .movie-list .item .video-title { white-space: normal; } .moj-content{display:none}`,
            JAVLIBRARY: `#leftmenu > table:last-child,#topbanner11{display:none}`,
            JABLE: `.video-img-box .title,.title{white-space:normal;max-height:auto}.container {max-width: 80% !important;} .right-sidebar {display: contents;max-width: 500px;} .right-sidebar .video-img-box {display:inline-block !important;} .right-sidebar>.gutter-20>.col-lg-12 {flex: 0 0 25% !important;} .justify-content-center,.h5,iframe,.asg-interstitial,.root--ujvuu ,.text-sponsor,.pb-3 .row .col-6:nth-child(2),.right-sidebar .row .col-6:nth-child(1){display:none}`,
            AV01: `.group:has(> div.overflow-hidden){display:none} .grid > div{grid-column:span 3 / span 3} .max-w-7xl{max-width: 90vw;}.space-y-4 > div{width: 33vw;display: -webkit-box;}`,
            MDME: `.detail_left ol li {height: 115px; }.detail_left { writing-mode: tb;}.width1200>div{float:none;width:100vw;} .width1200 {width: 100vw; min-width: 100vw; }.detail_right_div ul,.sugetVideo ul {display: grid;grid-template-columns: repeat(4, 1fr);}`,
            AVJOY: `.related-video .thumb-overlay,.related-video .content-info {width: 100%;}.container {max-width: 100vw;} .content-right{ width: 100%;}.content-left {;max-width: 70%;margin:0 auto;} .content-right{display: grid;grid: auto-flow /repeat(4, 1fr) ;} .ad-content-bot,.ad-content-side{display:none}`,
            MISSAV: `.sm\\:container {max-width: 100vw !important;}.order-first{width:100vw !important;} .video-player-container {width: 100%;} .-mt-6 {width: 80%;height: 80%;margin: 0 auto !important;} .related-videos {display: grid;grid-template-columns: repeat(4, 1fr);gap: 10px;} .order-last,.order-last >div {max-width: 100% !important;min-width: 100% !important;} .order-last > div {display:grid; grid-template-columns: repeat(4,minmax(0,1fr));gap: 1.25rem;} .ad-banner,.advertisement,.ads,.list-none,.space-y-6{display:none !important;} .content-without-search > .flex {flex-direction: column;}.ml-6 {margin-left: 0 !important;}`
        };

        const commonButtons = {
            javdb: true, javbus: true, javlibrary: true, jable: true, av01: true, avjoy: true, avsubtitles: true, subtitlecat: true, missav: true, btdig: true, btsow: true, hayav: true
        };

        this.SITE_CONFIG = {
            [this.SITES.JAVDB]: { titleSelector: this.SELECTORS.JAVDB_TITLE, codeSelector: this.SELECTORS.JAVDB_CODE, style: this.STYLES.JAVDB, pathCheck: "/v/", buttons: { ...commonButtons, javdb: false } },
            [this.SITES.JAVBUS]: { titleSelector: this.SELECTORS.JAVBUS_TITLE, style: this.STYLES.JAVBUS, pathCheck: /[A-Za-z]+-\d+/, buttons: { ...commonButtons, javbus: false } },
            [this.SITES.JAVLIBRARY]: { titleSelector: this.SELECTORS.JAVLIBRARY_TITLE, style: this.STYLES.JAVLIBRARY, pathCheck: "/?v=", buttons: { ...commonButtons } },
            [this.SITES.JABLE]: { titleSelector: this.SELECTORS.JABLE_TITLE, style: this.STYLES.JABLE, pathCheck: "/videos/", buttons: { ...commonButtons, jable: false } },
            [this.SITES.AVJOY]: { titleSelector: this.SELECTORS.AVJOY_TITLE, style: this.STYLES.AVJOY, pathCheck: "/video/", buttons: { ...commonButtons, avjoy: false } },
            [this.SITES.AV01]: { titleSelector: this.SELECTORS.AV01_TITLE, style: this.STYLES.AV01, pathCheck: "/video/", buttons: { ...commonButtons, av01: false } },
            [this.SITES.MDME]: { style: this.STYLES.MDME, pathCheck: "/", buttons: { ...commonButtons, javdb: false, javbus: false } },
            [this.SITES.MISSAV]: { titleSelector: this.SELECTORS.MISSAV_TITLE, style: this.STYLES.MISSAV, pathCheck: "/cn/", buttons: { ...commonButtons, missav: false } },
            // Magnet Site Configs
            [this.SITES.SUKEBEI]: { style: this.STYLES.SUKEBEI, isMagnetSite: true, magnetSelectors: { list: "tbody tr", hashLink: "td:nth-child(3) a:last-child", title: "td:nth-child(2) a", size: "td:nth-child(4)", date: "td:nth-child(5)", insertPoint: "td:nth-child(3)" } },
            [this.SITES.BTDIG]: { style: this.STYLES.BTDIG, isMagnetSite: true, magnetSelectors: { list: ".one_result", hashLink: ".torrent_name a", title: ".torrent_name a", size: ".torrent_size", date: ".torrent_age", insertPoint: ".torrent_magnet" } },
            [this.SITES.BTSOW]: { style: this.STYLES.BTSOW, isMagnetSite: true, magnetSelectors: { list: ".data-list .row", hashLink: "a", title: "a", size: ".size", date: ".date", insertPoint: "a" } },
        };
    }

    // ==================== 2. UTILITY FUNCTIONS ====================
    utils = {
        isIncludes: (domain) => window.location.hostname.includes(domain),
        isValidPath: (pathCheck) => typeof pathCheck === "string" ? window.location.href.includes(pathCheck) : window.location.href.match(pathCheck),
        waitForElement: (selector, timeoutMs = 3000) => new Promise((resolve, reject) => {
            const existing = document.querySelector(selector);
            if (existing) return resolve(existing);
            const observer = new MutationObserver(() => {
                const el = document.querySelector(selector);
                if (el) { observer.disconnect(); resolve(el); }
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
            if (timeoutMs > 0) setTimeout(() => { observer.disconnect(); reject(new Error(`waitForElement timeout: ${selector}`)); }, timeoutMs);
        }),
        moveElementToNextSiblingFirstChild: (selector) => {
            const element = document.querySelector(selector);

            if (!element) {
                console.error("The provided element is invalid.");
                return;
            }
            const nextSibling = element.nextElementSibling;

            if (!nextSibling) {
                console.warn("The element has no next sibling to move to.");
                return;
            }

            nextSibling.insertBefore(element, nextSibling.firstElementChild);
        },
        getCodeFromCurrentSite: (domain, selector) => {
            if (!this.utils.isIncludes(domain)) return null;
            const titleElement = document.querySelector(selector);
            const title = titleElement ? titleElement.textContent.trim() : "";
            let codeMatch = title.match(/[A-Za-z]+-\d+/) || document.title.match(/[A-Za-z]+-\d+/);
            if (codeMatch && codeMatch[0]) return codeMatch[0];
            const lastSegment = decodeURIComponent(window.location.pathname.split("/").filter(Boolean).pop() || "");
            codeMatch = lastSegment.match(/[A-Za-z]+-\d+/);
            return codeMatch ? codeMatch[0] : null;
        },
        gmFetch: (url, options = {}) => new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url,
                ...options,
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response);
                    } else {
                        reject(new Error(`Request failed with status ${response.status}`));
                    }
                },
                onerror: (error) => reject(new Error("Network error during request", { cause: error })),
            });
        }),
        NewElement: (tagName = "div", config = {}) => {
            const element = document.createElement(tagName);
            if (config.textContent) element.textContent = config.textContent;
            if (config.className) element.className = config.className;
            if (config.id) element.id = config.id;
            if (config.attributes) Object.entries(config.attributes).forEach(([key, value]) => element.setAttribute(key, value));
            if (config.events) Object.entries(config.events).forEach(([event, handler]) => element.addEventListener(event, handler));
            if (config.style) Object.assign(element.style, config.style);
            return element;
        },
    };

    // ==================== 3. DOM & UI MANIPULATION ====================
    dom = {
        injectStyleOnce: (style, idKey) => {
            if (!style || document.getElementById(idKey)) return;
            const styleElement = this.utils.NewElement("style", { id: idKey, textContent: style });
            document.head.appendChild(styleElement);
        },

        linkButtonDefs: {
            javbus: { text: "JAVBUS", color: "#3498db", url: (code) => `https://www.javbus.com/${code.replace("DSVR", "3DSVR")}` },
            javlibrary: { text: "Javlibrary", color: "#6027ae", url: (code) => `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${code}` },
            jable: { text: "Jable", color: "#27ae60", url: (code) => `https://jable.tv/videos/${code}/` },
            av01: { text: "AV01", color: "#9b59b6", url: (code) => `https://www.av01.tv/cn/search?q=${code}` },
            avjoy: { text: "AVJOY", color: "#f39c12", url: (code) => `https://avjoy.me/search/videos/${code}` },
            avsubtitles: { text: "AVSubtitles", color: "#e91e63", url: (code) => `https://www.avsubtitles.com/search_results.php?search=${code}` },
            subtitlecat: { text: "SubtitleCat", color: "#ff9800", url: (code) => `https://www.subtitlecat.com/index.php?search=${code}` },
            missav: { text: "MISSAV", color: "#3f51b5", url: (code) => `https://missav.ws/cn/search/${code}` },
            btdig: { text: "BTDIG", color: "#795548", url: (code) => `https://btdig.com/search?q=${code}` },
            btsow: { text: "BTSOW", color: "#607d8b", url: (code) => `https://btsow.pics/#/search/${code}` },
            hayav: { text: "HAYAV", color: "#07d8b6", url: (code) => `https://hayav.com/search/${code}` }
        },

        addLinkButtons: async (code, selector, buttonsConfig) => {
            const titleElement = await this.utils.waitForElement(selector).catch(() => document.querySelector(selector));
            if (!code || !titleElement) return;

            const buttonContainer = this.utils.NewElement("div", {
                id: "cross-site-button-container",
                style: { display: "inline-block", marginLeft: "10px" }
            });

            // JAVDB special handling (async search)
            if (buttonsConfig?.javdb) {
                try {
                    const response = await this.utils.gmFetch(`https://javdb.com/search?q=${encodeURIComponent(code)}`);
                    const doc = new DOMParser().parseFromString(response.responseText, "text/html");
                    const videoLink = doc.querySelector('a[href*="/v/"]');
                    const url = videoLink ? `https://javdb.com${videoLink.getAttribute("href")}` : `https://javdb.com/search?q=${encodeURIComponent(code)}`;
                    const btn = this.dom.createSingleButton("JAVDB", videoLink ? "#e74c3c" : "#e67e22", url);
                    buttonContainer.appendChild(btn);
                } catch (error) {
                    console.error("JAVDB search failed:", error);
                    const btn = this.dom.createSingleButton("搜JAVDB", "#e67e22", `https://javdb.com/search?q=${encodeURIComponent(code)}`);
                    buttonContainer.appendChild(btn);
                }
            }

            // Other sites
            Object.entries(this.dom.linkButtonDefs).forEach(([key, def]) => {
                if (buttonsConfig?.[key]) {
                    const btn = this.dom.createSingleButton(def.text, def.color, def.url(code));
                    buttonContainer.appendChild(btn);
                }
            });

            titleElement.parentNode.insertBefore(buttonContainer, titleElement.nextSibling);
        },

        createSingleButton: (text, bgColor, url) => {
            return this.utils.NewElement("a", {
                textContent: text,
                attributes: { href: url, target: "_blank" },
                style: { marginRight: "8px", padding: "4px 8px", color: "#fff", background: bgColor, border: "none", borderRadius: "3px", cursor: "pointer", textDecoration: "none", fontSize: "14px" }
            });
        },

        enhanceMagnetLinks: (config) => {
            const processNode = (node) => {
                if (!node || node.__tm_magnet_enhanced__) return;

                const selectors = config.magnetSelectors;
                const hashLinkEl = node.querySelector(selectors.hashLink);
                if (!hashLinkEl) return;

                const hashMatch = hashLinkEl.href.match(/[0-9a-fA-F]{40}/);
                if (!hashMatch) return;
                const hash = hashMatch[0].toLowerCase();

                const title = node.querySelector(selectors.title)?.innerText || `magnet-${hash.substring(0, 8)}`;
                const size = node.querySelector(selectors.size)?.innerText || "";
                const date = node.querySelector(selectors.date)?.innerText || "";
                const magnetUrl = `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(title)} ${size} ${date}`;

                const insertPoint = node.querySelector(selectors.insertPoint);
                if (!insertPoint) return;

                const parent = insertPoint.parentElement;

                // Copy button
                const copyBtn = this.utils.NewElement("a", {
                    textContent: "📋",
                    attributes: { href: "#", title: "Copy Magnet Link" },
                    style: { marginLeft: "8px", cursor: "pointer", textDecoration: "none" },
                    events: {
                        click: async (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            await navigator.clipboard.writeText(decodeURIComponent(magnetUrl));
                            e.currentTarget.textContent = "✅";
                            setTimeout(() => { e.currentTarget.textContent = "📋"; }, 2000);
                        }
                    }
                });

                // Preview button
                const previewBtn = this.utils.NewElement("a", {
                    textContent: "👁️",
                    attributes: { href: "#", title: "Preview Torrent Content" },
                    style: { marginLeft: "6px", cursor: "pointer", textDecoration: "none" },
                    events: {
                        click: (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            this.drawer.open(`https://magnet.pics/m/${hash}`, title);
                        }
                    }
                });

                parent.append(copyBtn, previewBtn);
                node.__tm_magnet_enhanced__ = true;
            };

            const run = () => document.querySelectorAll(config.magnetSelectors.list).forEach(processNode);
            run(); // Initial run

            // Observe for dynamically added nodes
            const observer = new MutationObserver((mutations) => {
                mutations.forEach(m => {
                    if (m.addedNodes.length) run();
                });
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }
    };

    // ==================== 4. DRAWER CLASS FOR PREVIEWS ====================
    drawer = (() => {
        let drawerInstance = null;

        const create = (options) => {
            const { direction = 'right', width = '40vw', title = 'Preview' } = options;

            if (drawerInstance) {
                try { document.body.removeChild(drawerInstance.element); } catch (e) { }
            }

            const element = this.utils.NewElement('div', {
                style: {
                    position: 'fixed', top: '0', [direction]: '0', width, height: '100vh', background: 'white',
                    boxShadow: '0 0 10px rgba(0,0,0,0.1)', transform: `translateX(${direction === 'left' ? '-100%' : '100%'})`,
                    transition: 'transform 0.3s ease', zIndex: '2147483647'
                }
            });

            const header = this.utils.NewElement('div', {
                style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderBottom: '1px solid #eee' }
            });
            const titleEl = this.utils.NewElement('h5', { textContent: title, style: { margin: '0' } });
            const closeBtn = this.utils.NewElement('button', {
                textContent: '×',
                style: { background: 'none', border: 'none', fontSize: '24px', cursor: 'pointer', color: '#666' }
            });
            const content = this.utils.NewElement('div', {
                style: { padding: '0', height: 'calc(100vh - 60px)', overflowY: 'auto' }
            });

            closeBtn.onclick = () => close();

            header.append(titleEl, closeBtn);
            element.append(header, content);
            document.body.appendChild(element);

            drawerInstance = { element, content };
            return drawerInstance;
        };

        const open = (url, title, width, direction) => {
            const instance = create({ title, width, direction });
            instance.content.innerHTML = `<iframe width='100%' height='100%' src='${url}' style='border:none;'></iframe>`;
            requestAnimationFrame(() => instance.element.style.transform = 'translateX(0)');
        };

        const close = () => {
            if (!drawerInstance) return;
            const { element } = drawerInstance;
            const direction = element.style.left === '0px' ? 'left' : 'right';
            element.style.transform = `translateX(${direction === 'left' ? '-100%' : '100%'})`;
            setTimeout(() => {
                if (drawerInstance && drawerInstance.element === element) {
                    drawerInstance.content.innerHTML = '';
                }
            }, 300);
        };

        return { open };
    })();

    // ==================== 5. SITE HANDLERS ====================
    handlers = {
        handleGenericSite: (siteKey, hooks = {}) => {
            console.log(siteKey, hooks)
            const config = this.SITE_CONFIG[siteKey];
            if (!config) return;

            // ===== JAVBUS.COM SPECIAL HANDLING =====
            if (siteKey === this.SITES.JAVBUS) {
                this.utils.moveElementToNextSiblingFirstChild(".masonry-brick:has(.avatar-box)");

                // 1. 替换所有 .photo-frame img 缩略图为大图
                [...document.querySelectorAll('.photo-frame img')].forEach((img) => {
                    if (img.src.includes('/pics/thumb/')) {
                        img.src = img.src
                            .replace('/pics/thumb/', '/pics/cover/')
                            .replace('.jpg', '_b.jpg');
                    }
                });
                // 2. 你可以在这里添加更多JAVBUS专属逻辑
            }

            if (config.isMagnetSite) {
                this.dom.injectStyleOnce(config.style, siteKey.replace(/\./g, "-"));
                this.dom.enhanceMagnetLinks(config);
                return;
            }

            if (config.style) this.dom.injectStyleOnce(config.style, siteKey.replace(/\./g, "-"));

            const isDetailPage = this.utils.isValidPath(config.pathCheck);
            if (isDetailPage) {
                const code = this.utils.getCodeFromCurrentSite(siteKey, config.titleSelector);
                if (!code) return;

                // if (!hooks.skipDefaultDetail) {
                this.dom.addLinkButtons(code, config.titleSelector, config.buttons);
                // }
                // if (typeof hooks.onDetailPage === "function") hooks.onDetailPage(config, code);

            } else {
                if (typeof hooks.onListPage === "function") hooks.onListPage(config);
            }
        },
        setupCookieMenu: () => {
            GM_registerMenuCommand("设置JAVBUS Cookie", async () => {
                const current = await GM_getValue("javbus_cookie", "");
                const input = prompt("请输入JAVBUS的Cookie:", current || "");
                if (input !== null) await GM_setValue("javbus_cookie", input.trim());
            });
            GM_registerMenuCommand("设置JAVDB Cookie", async () => {
                const current = await GM_getValue("javdb_cookie", "");
                const input = prompt("请输入JAVDB的Cookie:", current || "");
                if (input !== null) await GM_setValue("javdb_cookie", input.trim());
            });
        }
    };

    // ==================== 6. INITIALIZATION ====================
    init() {
        this.handlers.setupCookieMenu();
        const currentSiteKey = Object.keys(this.SITE_CONFIG).find(site => this.utils.isIncludes(site));

        if (currentSiteKey) {
            // Simplified routing
            this.handlers.handleGenericSite(currentSiteKey);
        } else {
            console.log("JableLinker: No handler for current site.");
        }
    }
}

// Launch the script
(function () {
    'use strict';
    new JableLinker().init();
})();