Jav跳转到Emby播放,支持 JavBus/Javdb/library/javmunu/XXXClub

在JavBus/Javdb/library/javmunu图书馆高亮emby存在的视频,并在详情页提供一键跳转功能

// ==UserScript==
// @name         Jav跳转到Emby播放,支持 JavBus/Javdb/library/javmunu/XXXClub
// @namespace    http://tampermonkey.net/
// @version      2025.3.7
// @description  在JavBus/Javdb/library/javmunu图书馆高亮emby存在的视频,并在详情页提供一键跳转功能
// @include      /^.*(jav|bus|dmm|see|cdn|fan){2}\..*$/
// @match        *://www.javbus.com/*
// @include      *://javdb*.com/v/*
// @match        *://javmenu.com/*
// @match        *://xxxclub.to/*
// @include      *://javdb*.com/search?q=*
// @match        *://www.javdb.com/*
// @match        *://javdb.com/*
// @include      *://*.javlib.com/*
// @include      *://*.javmenu.com/*
// @include      *://*.javlibrary.com/*
// @include      *://*/cn/*v=jav*
// @include      *://*/en/*v=jav*
// @include      *://*/tw/*v=jav*
// @include      *://*/ja/*v=jav*
// @include      /^.*(avmoo|avsox)\..*$/
// @include      *://avmoo.*/*/movie/*
// @include      *://avsox.*/*/movie/*
// @match        https://www.sehuatang.net/thread-*
// @match        https://www.sehuatang.net/forum.php?mod=viewthread&tid=*
// @match        https://.*/thread-*
// @match        https://.*/forum.php?mod=viewthread&tid=*
// @match        https://www.tanhuazu.com/threads/*
// @match       *://javbooks.com/content*censored/*.htm
// @match       *://jmvbt.com/content*censored/*.htm
// @match       *://*.com/content*censored/*.htm
// @include     *://*.cc/content_censored/*.htm
// @include     /^https:\/\/jbk008\.com\/serchinfo\_(censored|uncensored)\/topicsbt/
// @match       *://db.msin.jp/jp.page/movie?id=*
// @match       *://db.msin.jp/page/movie?id=*
// @include      *://*/works/detail/*
// @match        *://xslist.org/search?query=*
// @grant        GM_xmlhttpRequest
// @license MIT

// ==/UserScript==

const embyAPI = "279b8961e1fd44b1bf8ea4c025f67075";
const embyBaseUrl = "http://192.168.5.8:35405/";
const defaultColor = "#52b54b";  // HotPink

(function () {
    'use strict';

    // 新增 Cloudflare 检测函数
    function checkCloudflareChallenge() {
        const cloudflareSelectors = [
            '#challenge-form',         // Cloudflare验证表单
            '.cf-browser-verification',// 浏览器验证容器
            'div.ray-id',              // Ray ID 元素
            'div.cf-spinner-rotator',   // 加载动画
            'trk-page[data-title^="Just a moment"]' // 特定页面标题
        ];
        return cloudflareSelectors.some(selector => document.querySelector(selector)) ||
            document.title.includes('Just a moment') ||
            document.body.textContent.includes('Cloudflare');
    }
    // 新增等待函数
    function waitForCloudflare(callback, maxAttempts = 30, interval = 1000) {
        let attempts = 0;
        const checkInterval = setInterval(() => {
            if (!checkCloudflareChallenge()) {
                clearInterval(checkInterval);
                callback();
            } else if (attempts++ >= maxAttempts) {
                clearInterval(checkInterval);
                console.log('Cloudflare验证等待超时');
                callback();
            }
        }, interval);
    }


    class Base {
        fetchEmbyData(code, callback) {
            if (!code) {
                console.warn("番号为空,跳过请求");
                return; // 番号为空时直接返回
            }

            console.log('Fetching data for code:', code);

            GM_xmlhttpRequest({
                method: "GET",
                url: `${embyBaseUrl}emby/Users/${embyAPI}/Items?api_key=${embyAPI}&Recursive=true&IncludeItemTypes=Movie&SearchTerm=${code}`,
                headers: { accept: "application/json" },
                onload: (res) => {
                    try {
                        const data = JSON.parse(res.responseText);
                        callback(data);
                    } catch (error) {
                        console.error("Failed to parse response:", error);
                    }
                },
                onerror: (e) => {
                    console.error("Error fetching Emby data:", e);
                },
                ontimeout: () => {
                    console.error("Request to Emby timed out");
                }
            });
        }

        insertEmbyLink(targetElement, data) {
            const maxLinks = 5; // 限制最多插入 5 个链接
            let insertedLinks = 0;

            data.Items.forEach(item => {
                if (insertedLinks >= maxLinks) {
                    console.log("已达到最大链接插入数量,跳过剩余链接");
                    return;
                }

                const embyUrl = `${embyBaseUrl}web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`;
                console.log(`生成的 embyUrl: ${embyUrl}`);

                // 确保 targetElement 是 DOM 元素
                const domElement = targetElement instanceof jQuery ? targetElement[0] : targetElement;

                // 检查是否已经存在 "跳转到Emby" 链接
                console.log('正在检查是否存在跳转到Emby链接...');
                // 使用 class 判断是否已经插入
                const parentElement = domElement.parentElement || domElement; // 检查父级范围
                if (parentElement.querySelector(`a[href="${embyUrl}"]`)) {
                    console.log('跳转到Emby链接已存在,跳过插入');
                    return;
                }

                const embyUrlSpanStyle = `background: ${defaultColor}; border-radius: 3px; padding: 3px 6px;`;
                const embyUrlAStyle = `color: white; text-decoration: none;`;
                const embyLink = `<div style="${embyUrlSpanStyle}">
                                    <a href="${embyUrl}" style="${embyUrlAStyle}" target="_blank">
                                      <b>跳转到emby👉</b>
                                    </a>
                                  </div>`;

                // 使用 jQuery 插入链接
                console.log('正在插入跳转到Emby链接...');
                $(domElement).after(embyLink);
                insertedLinks++; // 记录已插入的链接数量
            });
        }

        highlightAndInsertEmbyLink(videos, extractFanhaoFunction, insertAfterSelector) {
            // console.log('Highlighting videos...', videos);
            const videoArray = Array.from(videos);

            videoArray.forEach(videoElement => {
                const fanhaos = extractFanhaoFunction(videoElement);
                // console.log('Fanhaos:', fanhaos);

                if (!fanhaos || fanhaos.length === 0) {
                    console.warn("未提取到番号,跳过该视频");
                    return; // 番号为空时跳过
                }

                const searchNextFanhao = (fanhaoIndex) => {
                    if (fanhaoIndex >= fanhaos.length) return;

                    let fanhao = fanhaos[fanhaoIndex];
                    this.fetchEmbyData(fanhao, (data) => {
                        if (data.Items.length > 0) {
                            const targetElement = insertAfterSelector
                                ? videoElement.querySelector(insertAfterSelector)
                                : videoElement;
                            const domElement = targetElement instanceof jQuery ? targetElement[0] : targetElement;
                            this.insertEmbyLink(domElement, data);

                            // 高亮
                            videoElement.style.borderWidth = "3px";
                            videoElement.style.borderStyle = "solid";
                            videoElement.style.borderColor = defaultColor;
                            videoElement.style.backgroundColor = defaultColor;
                        } else {
                            searchNextFanhao(fanhaoIndex + 1);
                        }
                    });
                };

                searchNextFanhao(0);
            });
        }
    }

    // 定义各站点处理类(保持空类结构)
    class JavBus extends Base { }
    class JavLibrary extends Base { }
    class Javdb extends Base { }
    class Javbooks extends Base { }
    class Avmoo extends Base { }
    class Sehuatang extends Base { }
    class Msin extends Base { }
    class Javmenu extends Base { }
    class XXXClub extends Base { }

    class Main {
        constructor() {
            console.log('Jav跳转Emby启动...');

            this.sites = {
                'javBus': {
                    // 选择器,用于判断当前页面是否属于 JavBus 网站。
                    // 这里通过选择页脚 `<footer>` 标签中包含文本 'JavBus' 来判断。
                    selector: "footer:contains('JavBus')",

                    // JavBus 的站点处理类。这是一个自定义的逻辑类,用于处理该站点的具体操作。
                    class: JavBus,

                    // 选择器,用于判断是否是 JavBus 网站的列表页(瀑布流页面)。
                    // 这里通过选择 `#waterfall`(id)下的 `.item.masonry-brick`(class)元素来进行判断。
                    listPageSelector: "#waterfall .item.masonry-brick",

                    // 定义在列表页中插入新内容的位置。
                    // 这里选择 `.item` 元素下的 `date` 标签作为插入位置的参考点。
                    listPageInsertAfter: ".item date",

                    // 在列表页中提取番号的逻辑。
                    // `el` 是当前列表项的 DOM 元素。
                    // 通过查询 `.item date` 元素来获取番号,如果找到对应的元素,则返回其文本内容(去除前后空格)。
                    // 如果找不到则返回空数组。
                    listPageExtract: (el) => {
                        const fanhaoElement = el.querySelector('.item date');  // 查找 `.item date` 元素
                        return fanhaoElement ? [fanhaoElement.textContent.trim()] : [];  // 提取文本并返回
                    },

                    // 选择器,用于判断是否是 JavBus 网站的详情页。
                    // 这里通过选择 `.col-md-3.info p span:nth-child(2)` 来判断,这个选择器通常用于获取影片的识别码/番号。
                    detailPageSelector: '.col-md-3.info p span:nth-child(2)',

                    // 定义在详情页中插入新内容的容器。
                    // 这里选择 `.col-md-3.info p span:nth-child(2):first`,表示在符合条件的第一个 `span` 元素中插入内容。
                    detailPageContainer: ".col-md-3.info p span:nth-child(2):first",

                    // 在详情页中提取番号的逻辑。
                    // 通过 jQuery 查询 `.col-md-3.info p` 的第一个段落,并查找其第二个 `span` 元素。
                    // 如果找到有效的内容,则返回其文本内容。
                    // 如果找不到则返回空数组。
                    detailPageExtract: () => {
                        const code = $('.col-md-3.info p').eq(0).find('span').eq(1).html();  // 查找番号
                        return code ? [code] : [];  // 提取文本并返回
                    }
                },

                'javmenu': {
                    selector: "footer:contains('JAVMENU V3')",
                    class: Javmenu,
                    listPageSelector: ".page-content .category-page.video-list-item",
                    listPageInsertAfter: ".card-title.text-dark",
                    listPageExtract: (el) => {
                        console.log(el);
                        const fanhaoElement = el.querySelector('.card-title.text-dark');
                        console.log(fanhaoElement);
                        return fanhaoElement ? [fanhaoElement.textContent.trim()] : [];
                    },
                    detailPageSelector: '.page-content .container-fluid .tab-content h1 strong',
                    detailPageContainer: ".page-content",
                    detailPageExtract: () => {
                        const code = $('.page-content .container-fluid .tab-content h1 strong').text().trim().split(' ')[0];
                        return code ? [code] : [];
                    }
                },
                'xxxclub': {
                    selector: ".page-footer:contains('XXXClub')",
                    // selector: "div.page-footer:contains('XXXClub 2020 - 2025')", // 通过页脚标识站点
                    class: XXXClub,
                    listPageSelector: ".main-content ul li", // 列表项选择器
                    listPageInsertAfter: "span:nth-of-type(2) > a", // 在标题链接后插入
                    listPageExtract: (el) => {
                        const aElement = el.querySelector('span:nth-of-type(2) > a');
                        if (!aElement) return [];
                        const title = aElement.textContent.trim();
                        // console.log(title);

                        // 从标题文本提取番号
                        // 情况一:Brand后接三组两位数字: (格式示例: Brand YY MM DD)
                        const case1Match = title.match(/^(\S+)\s+(\d{2})\s+(\d{2})\s+(\d{2})/);
                        // return match ? [`${match[1]}.${match[2]}.${match[3]}.${match[4]}`] : [];
                        // console.log(match);
                        if (case1Match) {
                            // 组合成标准番号格式: Brand.YY.MM.DD
                            return [`${case1Match[1]}.${case1Match[2]}.${case1Match[3]}.${case1Match[4]}`];
                        }


                        // 情况二:匹配括号内的日期(DD.MM.YYYY)
                        const case2Match = title.match(/^(\S+?) - .*?\((\d{2})\.(\d{2})\.(\d{4})\)$/);
                        if (case2Match) {
                            const [, brand, dd, mm, yyyy] = case2Match;
                            const yy = yyyy.slice(-2); // 提取年份后两位
                            return [`${brand}.${yy}.${mm}.${dd}`];
                        }


                        // 情况三:匹配品牌 - 演员 - 标题的结构
                        const case3Match = title.match(/^(\S+) - .+? - (?!.*-)(.+)$/);
                        if (case3Match) {
                            return [`${case3Match[1]} ${case3Match[2]}`];
                        }

                        // 情况四:提取前五个空格前的部分
                        let currentIndex = -1;
                        let found = true;
                        for (let i = 0; i < 5; i++) {
                            currentIndex = title.indexOf(' ', currentIndex + 1);
                            if (currentIndex === -1) {
                                found = false;
                                break;
                            }
                        };
                        return found ? [title.substring(0, currentIndex)] : [];
                    },
                },
                'javLibrary': {
                    selector: "#bottomcopyright:contains('JAVLibrary')",
                    class: JavLibrary,
                    listPageSelector: ".video",
                    listPageInsertAfter: "a",
                    detailPageSelector: '#content #video_title #video_jacket_info #video_info .item .text',
                    detailPageContainer: "#video_info",
                    commentPageSelector: "#video_comments .comment",
                    commentPageInsertAfter: "strong",
                    listPageExtract: (el) => {
                        const fanhao = el.children[0]?.title.split(" ")[0] || el.children[1]?.title.split(" ")[0];
                        return fanhao ? [fanhao] : [];
                    },
                    detailPageExtract: () => {
                        const code = $('#video_info .item').eq(0).find('.text').html();
                        return code ? [code] : [];
                    },
                    commentPageExtract: (el) => {
                        const anchorElement = el.querySelector('a[href^="videoreviews.php?v="]');
                        return anchorElement ? [anchorElement.textContent.split(" ")[0]] : [];
                    }
                },
                'javdb': {
                    selector: "#footer:contains('javdb')",
                    class: Javdb,
                    listPageSelector: ".movie-list .item",
                    listPageInsertAfter: ".video-title strong", // 新增插入位置控制
                    detailPageSelector: 'body > section > div > div.video-detail > h2 > strong',
                    detailPageContainer: ".panel.movie-panel-info .value:first",
                    listPageExtract: (el) => {
                        const result = [];
                        const videoTitleElement = el.querySelector('.video-title strong');
                        if (videoTitleElement) {
                            const strongText = videoTitleElement.textContent.trim();
                            // 检查 strong 文本中是否包含至少 3 位数字
                            const hasThreeDigits = (strongText.match(/\d/g) || []).length >= 3;
                            if (hasThreeDigits) {
                                // 仅移除空格,保留其他字符
                                const processed = strongText.replace(/ /g, '');
                                // 仅移除第一个出现的空格
                                // const processed = strongText.replace(/ /, '');
                                result.push(processed);
                            } else {
                                // 克隆节点以避免修改原始 DOM
                                const videoTitle = el.querySelector('.video-title');
                                const clonedTitle = videoTitle.cloneNode(true);
                                // 处理克隆节点中的 strong 标签第一个空格
                                const clonedStrong = clonedTitle.querySelector('strong');
                                if (clonedStrong) {
                                    clonedStrong.textContent = clonedStrong.textContent
                                        .trim()
                                        .replace(/ /g, ''); // 仅替换第一个空格
                                }
                                // 统一清理所有非字母数字字符
                                const fullTitle = clonedTitle.textContent
                                    .trim()
                                    .replace(/[^a-zA-Z0-9]+/g, ' ') // 合并非字母数字字符为空格
                                    .trim();
                                result.push(fullTitle);
                            }
                        }
                        return result;
                    },

                    detailPageExtract: () => {
                        const code = $('body > section > div > div.video-detail > h2 > strong').text().trim().split(' ')[0];
                        return code ? [code] : [];
                    }
                },
                'javbooks': {
                    selector: "#Declare_box:contains('javbooks')",
                    class: Javbooks,
                    detailPageSelector: '#info > div:nth-child(2) > font',
                    detailPageContainer: "#info",
                    detailPageExtract: () => {
                        const code = $('#info > div:nth-child(2) > font').text().trim().split(' ')[0];
                        return code ? [code] : [];
                    }
                },
                'avmoo': {
                    selector: "footer:contains('AVMOO')",
                    class: Avmoo,
                    listPageSelector: "#waterfall .item",  // 根据id和class判断瀑布/列表页
                    listPageInsertAfter: ".item date", // 新增插入位置控制
                    listPageExtract: (el) => {  // 列表页提取番号
                        const fanhaoElement = el.querySelector('.item date');
                        return fanhaoElement ? [fanhaoElement.textContent.trim()] : [];
                    },
                    detailPageSelector: '.col-md-3.info p span:nth-child(2)',
                    detailPageContainer: ".col-md-3.info",
                    detailPageExtract: () => {
                        const code = $('.col-md-3.info p').eq(0).find('span').eq(1).html();
                        return code ? [code] : [];
                    }
                },
                'sehuatang': {
                    selector: "#flk:contains('色花堂')",
                    class: Sehuatang,
                    detailPageCodeRegex: /([a-zA-Z]{2,15}[-\s]?\d{2,15}|FC2PPV-[^\d]{0,5}\d{6,7})/i,
                    detailPageContainer: "#pgt",
                    detailPageExtract: () => {
                        const str = document.title.split(" ")[0];
                        return str.match(this.detailPageCodeRegex) || [];
                    }
                },
                'msin': {
                    selector: "#footer:contains('db.msin.jp')",
                    class: Msin,
                    detailPageSelector: 'div.mv_pn',
                    detailPageContainer: "#top_content",
                    detailPageExtract: () => {
                        const code = $('div.mv_pn').text().trim().split(' ')[0];
                        return code ? [code] : [];
                    }
                }
            };

            this.site = Object.keys(this.sites).find(key => $(this.sites[key].selector).length) || null;
            console.log('Matched site:', this.site);

            this.siteClass = this.site ? this.sites[this.site].class : null;
            // console.log('Site class:', this.siteClass);
        }

        make() {
            if (!this.siteClass) return;

            const siteConfig = this.sites[this.site];
            // console.log('Site Config:', this.siteConfig);
            const instance = new siteConfig.class();

            console.log('判断是否是列表页 $(siteConfig.listPageSelector).length:', $(siteConfig.listPageSelector).length);
            console.log('判断是否是评论页 $(siteConfig.commentPageSelector).length:', $(siteConfig.commentPageSelector).length);

            // 处理列表页
            if ($(siteConfig.listPageSelector).length > 0) {
                console.log('处理列表页', $(siteConfig.listPageSelector).length);
                instance.highlightAndInsertEmbyLink(
                    $(siteConfig.listPageSelector),
                    (el) => siteConfig.listPageExtract ? siteConfig.listPageExtract(el) : [],
                    siteConfig.listPageInsertAfter ? siteConfig.listPageInsertAfter : null // 传递插入位置选择器
                );

            }
            // 处理详情页
            else if ($(siteConfig.detailPageSelector).length > 0) {
                console.log('处理详情页', $(siteConfig.detailPageSelector).length);
                const codes = siteConfig.detailPageExtract ? siteConfig.detailPageExtract() : [];
                codes.forEach(code => {
                    instance.fetchEmbyData(code, (data) => {
                        if (data.Items.length > 0) {
                            // instance.insertEmbyLink($(siteConfig.detailPageContainer), data);
                            const detailContainer = $(siteConfig.detailPageContainer);
                            if (detailContainer.length > 0) {
                                instance.insertEmbyLink(detailContainer[0], data);
                            }
                        }
                    });
                });
            }
            // 处理评论页
            else if ($(siteConfig.commentPageSelector).length > 0) {
                console.log('处理评论页');
                // 添加对应的评论页选择器和处理逻辑
                instance.highlightAndInsertEmbyLink(
                    $(siteConfig.commentPageSelector),
                    (el) => siteConfig.commentPageExtract ? siteConfig.commentPageExtract(el) : [],
                    siteConfig.commentPageInsertAfter
                );
            }
        }
    }



    // 添加浮动按钮
    function addFloatingButton() {
        const button = document.createElement('div');
        button.textContent = '运行脚本';
        button.style.position = 'fixed';
        button.style.left = '10px';
        button.style.top = '50%';
        button.style.transform = 'translateY(-50%)';
        button.style.backgroundColor = '#52b54b';
        button.style.color = 'white';
        button.style.padding = '10px 15px';
        button.style.borderRadius = '5px';
        button.style.cursor = 'pointer';
        button.style.zIndex = 9999;
        button.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)';

        button.addEventListener('click', () => {
            console.log('运行脚本按钮被点击');
            new Main().make();
        });

        document.body.appendChild(button);
    }

    // 修改初始化逻辑
    setTimeout(() => {
        // 先添加浮动按钮(用户可手动触发)
        addFloatingButton();

        // 检测并等待 Cloudflare
        waitForCloudflare(() => {
            console.log('Cloudflare验证完成,启动主逻辑');
            new Main().make();
        });
    }, 1000);

})();