Douban Hover Card

鼠标悬停在豆瓣链接上时,显示电影海报、评分、简介及详细信息。优化了防闪烁、关闭不及时以及海报加载失败的问题。

// ==UserScript==
// @name         Douban Hover Card
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  鼠标悬停在豆瓣链接上时,显示电影海报、评分、简介及详细信息。优化了防闪烁、关闭不及时以及海报加载失败的问题。
// @author       zuoans
// @match        *://pterclub.com/details.php?id=*
// @exclude      *.douban.com/*
// @grant        GM_xmlhttpRequest
// @connect      movie.douban.com
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ------------------------------
    // 1. 创建或获取悬浮窗 (无变化)
    // ------------------------------
    let tooltipDiv;
    let activeLink = null;
    let cache = {};

    function createTooltip() {
        tooltipDiv = document.createElement("div");
        tooltipDiv.id = "douban-tooltip";
        tooltipDiv.style.position = "absolute";
        tooltipDiv.style.backgroundColor = "#fff";
        tooltipDiv.style.border = "1px solid #ccc";
        tooltipDiv.style.padding = "12px";
        tooltipDiv.style.boxShadow = "0 6px 12px rgba(0, 0, 0, 0.2)";
        tooltipDiv.style.display = "none";
        tooltipDiv.style.zIndex = "9999";
        tooltipDiv.style.maxWidth = "450px";
        tooltipDiv.style.fontSize = "13px";
        tooltipDiv.style.lineHeight = "1.6";
        tooltipDiv.style.borderRadius = "8px";
        tooltipDiv.style.textAlign = "left";
        document.body.appendChild(tooltipDiv);
        return tooltipDiv;
    }

    tooltipDiv = document.getElementById("douban-tooltip") || createTooltip();


    // ------------------------------
    // 2. 监听鼠标事件 (无变化)
    // ------------------------------
    let hideTooltipTimeout;
    const hideDelay = 300;

    const scheduleHide = () => {
        clearTimeout(hideTooltipTimeout);
        hideTooltipTimeout = setTimeout(() => {
            tooltipDiv.style.display = "none";
            activeLink = null;
        }, hideDelay);
    };

    const cancelHide = () => {
        clearTimeout(hideTooltipTimeout);
    };

    document.addEventListener("mouseover", function (e) {
        const target = e.target.closest('a[href*="movie.douban.com/subject/"]');
        if (target) {
            cancelHide();
            if (target !== activeLink) {
                activeLink = target;
                fetchDoubanInfo(target.href, tooltipDiv, target);
            }
        }
    });

    document.addEventListener("mouseout", function (e) {
        const target = e.target.closest('a[href*="movie.douban.com/subject/"]');
        if (target && !tooltipDiv.contains(e.relatedTarget)) {
            scheduleHide();
        }
    });

    tooltipDiv.addEventListener("mouseover", cancelHide);
    tooltipDiv.addEventListener("mouseout", (e) => {
        if (!activeLink || !activeLink.contains(e.relatedTarget)) {
            scheduleHide();
        }
    });

    // ------------------------------
    // 3. 解析所有信息 (无变化)
    // ------------------------------
    function parseAllInfo(doc) {
        const info = {};
        const infoDiv = doc.querySelector("#info");
        if (!infoDiv) return info;

        const keyMap = {
            '导演': 'director', '编剧': 'writer', '主演': 'cast', '类型': 'genre',
            '制片国家/地区': 'country', '语言': 'language', '上映日期': 'releaseDate',
            '片长': 'runtime', '又名': 'aka', 'IMDb': 'imdb'
        };

        const plElements = infoDiv.querySelectorAll("span.pl");
        plElements.forEach(pl => {
            const label = pl.textContent.replace(/[::\s]/g, '').trim();
            const key = keyMap[label];
            if (key) {
                let currentNode = pl;
                let content = '';
                while (currentNode.nextSibling && currentNode.nextSibling.nodeName.toLowerCase() !== 'br' && (currentNode.nextSibling.nodeName.toLowerCase() !== 'span' || !currentNode.nextSibling.classList.contains('pl'))) {
                    currentNode = currentNode.nextSibling;
                    content += currentNode.textContent.trim() + ' ';
                }
                info[key] = content.replace(/\/$/, '').trim() || '暂无';
            }
        });
        return info;
    }

    // ------------------------------
    // 4. 获取并展示豆瓣信息 (【关键修改】增加图片后备逻辑)
    // ------------------------------
    function fetchDoubanInfo(doubanUrl, tooltip, target) {
        if (cache[doubanUrl]) {
            displayTooltip(cache[doubanUrl], tooltip, target);
            return;
        }

        tooltip.innerHTML = '<div style="text-align: center; padding: 20px;">正在加载...</div>';
        positionTooltip(tooltip, target);
        tooltip.style.display = 'block';

        GM_xmlhttpRequest({
            method: "GET",
            url: doubanUrl,
            onload: function (response) {
                if (response.status !== 200) {
                    tooltip.innerHTML = `<div style="color: red;">请求失败: ${response.status}</div>`;
                    return;
                }

                const parser = new DOMParser();
                const doc = parser.parseFromString(response.responseText, "text/html");

                const info = parseAllInfo(doc);
                const title = doc.querySelector('h1 span[property="v:itemreviewed"]')?.textContent.trim() || "未知标题";
                const year = doc.querySelector('h1 .year')?.textContent.replace(/[\(\)]/g, '') || "";
                const rating = doc.querySelector("strong.rating_num")?.textContent.trim() || "暂无评分";

                // 【修改】同时获取原始海报和高清海报地址
                const originalCover = doc.querySelector("#mainpic img")?.src || "";
                const largeCover = originalCover.replace(/img\d+\.doubanio\.com\/view\/photo\/s_ratio_poster/, 'img1.doubanio.com/view/photo/l/public');

                let summary = doc.querySelector("div.related-info span[property='v:summary']")?.textContent.trim().replace(/^\s*/gm, '') || "暂无简介";
                if (summary.length > 250) summary = summary.substring(0, 250) + "...";

                if (info.cast && info.cast.split(' / ').length > 4) {
                    info.cast = info.cast.split(' / ').slice(0, 4).join(' / ') + ' / 更多...';
                }
                if (info.aka && info.aka.split(' / ').length > 4) {
                    info.aka = info.aka.split(' / ').slice(0, 4).join(' / ') + ' / 更多...';
                }

                // 【修改】将两个海报地址都存起来
                const data = { title, year, rating, cover: { large: largeCover, original: originalCover }, summary, info };
                cache[doubanUrl] = data;
                displayTooltip(data, tooltip, target);
            },
            onerror: function (err) {
                console.error("网络请求出错:", err);
                tooltip.innerHTML = `<div style="color: red;">网络请求出错</div>`;
            }
        });
    }

    function displayTooltip(data, tooltip, target) {
        // 【关键修改】在这里实现海报的智能加载和界面布局优化
        tooltip.innerHTML = `
            <div style="display: flex; gap: 15px;">
                <div style="flex-shrink: 0; width: 100px; min-height: 140px;">
                    <img src="${data.cover.large}"
                         alt="封面: ${data.title}"
                         referrerpolicy="no-referrer"
                         style="width: 100px; height: auto; display: block; border-radius: 4px; background-color: #f0f0f0;"
                         onerror="this.onerror=null; this.src='${data.cover.original}';">
                </div>
                <div style="flex-grow: 1; min-width: 0;">
                    <h3 style="margin:0 0 8px 0; font-size: 16px; font-weight: 600;">${data.title} (${data.year})</h3>
                    <p style="margin: 2px 0;"><strong>评分:</strong> ⭐ ${data.rating}</p>
                    <p style="margin: 2px 0; word-wrap: break-word;"><strong>类型:</strong> ${data.info.genre || '暂无'}</p>
                    <p style="margin: 2px 0; word-wrap: break-word;"><strong>主演:</strong> ${data.info.cast || '暂无'}</p>
                </div>
            </div>
            <div style="margin-top: 10px; border-top: 1px solid #eaeaea; padding-top: 10px;">
                <p style="margin: 2px 0;"><strong>导演:</strong> ${data.info.director || '暂无'}</p>
                <p style="margin: 2px 0;"><strong>编剧:</strong> ${data.info.writer || '暂无'}</p>
                <p style="margin: 2px 0;"><strong>国家/地区:</strong> ${data.info.country || '暂无'}</p>
                <p style="margin: 2px 0;"><strong>语言:</strong> ${data.info.language || '暂无'}</p>
                <p style="margin: 2px 0;"><strong>上映日期:</strong> ${data.info.releaseDate || '暂无'}</p>
                <p style="margin: 2px 0;"><strong>片长:</strong> ${data.info.runtime || '暂无'}</p>
                <p style="margin: 2px 0; word-wrap: break-word;"><strong>又名:</strong> ${data.info.aka || '暂无'}</p>
                <p style="margin: 2px 0;"><strong>IMDb:</strong> ${data.info.imdb || '暂无'}</p>
            </div>
            <div style="margin-top: 10px; border-top: 1px solid #eaeaea; padding-top: 8px;">
                <p style="margin: 0; line-height: 1.5;">${data.summary}</p>
            </div>
        `;
        positionTooltip(tooltip, target);
        tooltip.style.display = 'block';
    }

    // ------------------------------
    // 5. 定位悬浮窗 (无变化)
    // ------------------------------
    function positionTooltip(tooltip, target) {
        const rect = target.getBoundingClientRect();
        let top = rect.bottom + window.scrollY;
        let left = rect.left + window.scrollX;

        tooltip.style.display = 'block';
        const tooltipRect = tooltip.getBoundingClientRect();

        if (left + tooltipRect.width > window.innerWidth) {
            left = window.innerWidth - tooltipRect.width - 20;
        }
        if (top + tooltipRect.height > (window.innerHeight - 10)) {
            top = rect.top + window.scrollY - tooltipRect.height;
        }

        tooltip.style.left = `${left < 0 ? 10 : left}px`;
        tooltip.style.top = `${top}px`;
    }
})();