DingXX HLTB

在 DingXX 游戏页面显示 HowLongToBeat 通关时长数据

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         DingXX HLTB
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  在 DingXX 游戏页面显示 HowLongToBeat 通关时长数据
// @author       You
// @match        https://dingxx.com/games/*
// @match        https://www.dingxx.com/games/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @license MIT
// @connect      summer-queen-c430.3055632901.workers.dev
// ==/UserScript==

(function () {
    'use strict';

    const HLTB_API = 'https://summer-queen-c430.3055632901.workers.dev/search';

    GM_addStyle(`
        .hltb-widget {
            background: #ffffff;
            border-radius: 12px;
            padding: 16px 20px;
            margin: 16px 0;
            color: #111827;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
            border: 1px solid #E5E7EB;
        }
        /* 深色模式支持 */
        html.dark .hltb-widget,
        .dark .hltb-widget {
            background: #1F2937;
            color: #F9FAFB;
            border-color: #374151;
        }
        .hltb-header {
            display: flex;
            align-items: center;
            gap: 16px;
            margin-bottom: 16px;
            padding-bottom: 12px;
            border-bottom: 1px solid #E5E7EB;
        }
        html.dark .hltb-header,
        .dark .hltb-header {
            border-bottom-color: #374151;
        }
        .hltb-cover {
            width: 60px;
            height: 80px;
            border-radius: 8px;
            object-fit: cover;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
        }
        .hltb-info {
            flex: 1;
        }
        .hltb-game-title {
            font-size: 16px;
            font-weight: 700;
            color: #111827;
            margin-bottom: 4px;
        }
        html.dark .hltb-game-title,
        .dark .hltb-game-title {
            color: #F9FAFB;
        }
        .hltb-meta {
            display: flex;
            gap: 12px;
            flex-wrap: wrap;
            align-items: center;
        }
        .hltb-meta-item {
            font-size: 13px;
            color: #6B7280;
        }
        html.dark .hltb-meta-item,
        .dark .hltb-meta-item {
            color: #9CA3AF;
        }
        .hltb-score {
            background: linear-gradient(135deg, #F97316, #EA580C);
            color: #fff;
            padding: 2px 10px;
            border-radius: 6px;
            font-weight: 700;
            font-size: 13px;
        }
        .hltb-times-grid {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 12px;
            margin-bottom: 16px;
        }
        .hltb-time-card {
            background: #F9FAFB;
            border: 1px solid #E5E7EB;
            border-radius: 10px;
            padding: 14px 12px;
            text-align: center;
            transition: all 0.2s ease;
        }
        html.dark .hltb-time-card,
        .dark .hltb-time-card {
            background: #374151;
            border-color: #4B5563;
        }
        .hltb-time-card:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
        }
        .hltb-time-icon {
            font-size: 20px;
            margin-bottom: 6px;
        }
        .hltb-time-label {
            font-size: 12px;
            color: #6B7280;
            margin-bottom: 6px;
        }
        html.dark .hltb-time-label,
        .dark .hltb-time-label {
            color: #9CA3AF;
        }
        .hltb-time-value {
            font-size: 20px;
            font-weight: 800;
            color: #F97316;
        }
        .hltb-stats {
            background: #F9FAFB;
            border-radius: 8px;
            padding: 12px 16px;
            border: 1px solid #E5E7EB;
        }
        html.dark .hltb-stats,
        .dark .hltb-stats {
            background: #374151;
            border-color: #4B5563;
        }
        .hltb-stats-row {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 0;
            border-bottom: 1px solid #E5E7EB;
        }
        html.dark .hltb-stats-row,
        .dark .hltb-stats-row {
            border-bottom-color: #4B5563;
        }
        .hltb-stats-row:last-child {
            border-bottom: none;
        }
        .hltb-stats-label {
            font-size: 13px;
            color: #6B7280;
            font-weight: 500;
        }
        html.dark .hltb-stats-label,
        .dark .hltb-stats-label {
            color: #9CA3AF;
        }
        .hltb-stats-values {
            display: flex;
            gap: 16px;
            font-size: 13px;
        }
        .hltb-stats-values span {
            color: #374151;
        }
        html.dark .hltb-stats-values span,
        .dark .hltb-stats-values span {
            color: #D1D5DB;
        }
        .hltb-stats-values .fastest { color: #10B981; }
        .hltb-stats-values .slowest { color: #EF4444; }
        .hltb-loading {
            text-align: center;
            padding: 32px;
            color: #6B7280;
        }
        .hltb-loading-spinner {
            width: 32px;
            height: 32px;
            border: 3px solid #E5E7EB;
            border-top-color: #F97316;
            border-radius: 50%;
            animation: hltb-spin 1s linear infinite;
            margin: 0 auto 12px;
        }
        @keyframes hltb-spin {
            to { transform: rotate(360deg); }
        }
        .hltb-error {
            text-align: center;
            padding: 24px;
            color: #EF4444;
        }
        .hltb-section-title {
            display: flex;
            align-items: center;
            gap: 8px;
            font-size: 15px;
            font-weight: 600;
            color: #F97316;
            margin-bottom: 12px;
        }
    `);

    function getGameName() {
        // 方法1: 从URL提取英文名 (如 god-of-war)
        const urlMatch = window.location.pathname.match(/\/games\/[^-]+-(.+)$/);
        if (urlMatch) {
            const slug = urlMatch[1].replace(/-/g, ' ');
            // 转换为标题格式
            const titleCase = slug.split(' ').map(word =>
                word.charAt(0).toUpperCase() + word.slice(1)
            ).join(' ');
            console.log('[HLTB] 从URL提取:', titleCase);
            return titleCase;
        }

        // 方法2: 从页面标题提取
        const pageTitle = document.title;
        if (pageTitle) {
            // 通常标题格式为 "游戏名 - DingXX"
            const match = pageTitle.match(/^(.+?)\s*[-|]/);
            if (match) {
                console.log('[HLTB] 从标题提取:', match[1]);
                return match[1].trim();
            }
        }

        // 方法3: 从H1标题提取
        const h1 = document.querySelector('h1');
        if (h1) {
            console.log('[HLTB] 从H1提取:', h1.innerText);
            return h1.innerText.trim();
        }

        return null;
    }

    function formatHours(hours) {
        if (!hours || hours <= 0) return '-';
        const h = Math.floor(hours);
        const m = Math.round((hours - h) * 60);
        if (h === 0) return `${m}分钟`;
        if (m > 0) return `${h}h ${m}m`;
        return `${h}h`;
    }

    function formatSeconds(seconds) {
        if (!seconds || seconds <= 0) return '-';
        return formatHours(seconds / 3600);
    }

    function createWidget(data) {
        const widget = document.createElement('div');
        widget.className = 'hltb-widget';

        if (!data.found) {
            widget.innerHTML = `<div class="hltb-error">❌ 未找到 "${data.searchName || '游戏'}" 的通关时长数据</div>`;
            return widget;
        }

        widget.innerHTML = `
            <div class="hltb-section-title">
                ⏱️ 通关时长
            </div>
            <div class="hltb-header">
                ${data.fullImageUrl ? `<img class="hltb-cover" src="${data.fullImageUrl}" alt="">` : ''}
                <div class="hltb-info">
                    <div class="hltb-game-title">${data.gameName}</div>
                    <div class="hltb-meta">
                        <span class="hltb-meta-item">🎮 ${data.profilePlatforms || 'PlayStation'}</span>
                        ${data.reviewScore ? `<span class="hltb-score">${data.reviewScore}</span>` : ''}
                    </div>
                </div>
            </div>
            
            <div class="hltb-times-grid">
                <div class="hltb-time-card">
                    <div class="hltb-time-icon">🎯</div>
                    <div class="hltb-time-label">主线剧情</div>
                    <div class="hltb-time-value">${formatHours(data.mainStoryHours)}</div>
                </div>
                <div class="hltb-time-card">
                    <div class="hltb-time-icon">⭐</div>
                    <div class="hltb-time-label">主线 + 额外</div>
                    <div class="hltb-time-value">${formatHours(data.mainExtraHours)}</div>
                </div>
                <div class="hltb-time-card">
                    <div class="hltb-time-icon">🏆</div>
                    <div class="hltb-time-label">白金通关</div>
                    <div class="hltb-time-value">${formatHours(data.completionistHours)}</div>
                </div>
            </div>

            <div class="hltb-stats">
                <div class="hltb-stats-row">
                    <span class="hltb-stats-label">🎯 主线平均</span>
                    <div class="hltb-stats-values">
                        <span>均 ${formatSeconds(data.compMainAvg)}</span>
                        <span class="fastest">快 ${formatSeconds(data.compMainLow)}</span>
                        <span class="slowest">慢 ${formatSeconds(data.compMainHigh)}</span>
                    </div>
                </div>
                <div class="hltb-stats-row">
                    <span class="hltb-stats-label">🏆 白金平均</span>
                    <div class="hltb-stats-values">
                        <span>均 ${formatSeconds(data.comp100Avg)}</span>
                        <span class="fastest">快 ${formatSeconds(data.comp100Low)}</span>
                        <span class="slowest">慢 ${formatSeconds(data.comp100High)}</span>
                    </div>
                </div>
            </div>
        `;
        return widget;
    }

    function findInsertionPoint() {
        // 方法1: 在导航标签栏(奖杯/评价/话题/排行)之后插入
        const tabsContainer = document.querySelector('div.bg-background.rounded-lg.border.flex.gap-1.overflow-x-auto');
        if (tabsContainer) {
            return { element: tabsContainer, position: 'after' };
        }

        // 方法2: 查找包含"奖杯"链接的导航栏
        const trophyLink = Array.from(document.querySelectorAll('a')).find(a =>
            a.textContent.includes('奖杯') && a.href.includes(window.location.pathname)
        );
        if (trophyLink) {
            const navContainer = trophyLink.closest('div.rounded-lg');
            if (navContainer) {
                return { element: navContainer, position: 'after' };
            }
        }

        // 方法3: 在H1标题之后插入
        const h1 = document.querySelector('h1');
        if (h1) {
            const container = h1.closest('div.flex') || h1.parentElement;
            if (container) {
                return { element: container, position: 'after' };
            }
        }

        // 方法4: 主内容区域
        const main = document.querySelector('main');
        if (main && main.children.length > 0) {
            // 跳过前面的头部区域,找到第一个内容卡片
            for (let i = 0; i < main.children.length; i++) {
                const child = main.children[i];
                if (child.classList.contains('bg-white') || child.querySelector('.bg-white')) {
                    return { element: child, position: 'before' };
                }
            }
            return { element: main.children[0], position: 'after' };
        }

        return null;
    }

    function init() {
        // 检查是否已经插入过组件
        if (document.querySelector('.hltb-widget')) {
            console.log('[HLTB] 组件已存在,跳过');
            return;
        }

        // 检查是否在游戏详情页
        if (!window.location.pathname.match(/\/games\/[^/]+$/)) {
            console.log('[HLTB] 非游戏详情页,跳过');
            return;
        }

        // 等待页面完全加载
        setTimeout(() => {
            // 再次检查(防止并发)
            if (document.querySelector('.hltb-widget')) return;

            const gameName = getGameName();
            if (!gameName) {
                console.log('[HLTB] 未能提取游戏名');
                return;
            }

            console.log('[HLTB] 开始查询:', gameName);

            const placeholder = document.createElement('div');
            placeholder.className = 'hltb-widget hltb-loading';
            placeholder.innerHTML = '<div class="hltb-loading-spinner"></div>正在获取通关时长数据...';

            const insertPoint = findInsertionPoint();
            if (insertPoint) {
                if (insertPoint.position === 'before') {
                    insertPoint.element.parentNode.insertBefore(placeholder, insertPoint.element);
                } else {
                    insertPoint.element.parentNode.insertBefore(placeholder, insertPoint.element.nextSibling);
                }
            } else {
                // 兜底:插入到body
                document.body.insertBefore(placeholder, document.body.firstChild);
            }

            GM_xmlhttpRequest({
                method: 'GET',
                url: `${HLTB_API}?name=${encodeURIComponent(gameName)}`,
                onload: function (response) {
                    try {
                        const data = JSON.parse(response.responseText);
                        placeholder.replaceWith(createWidget(data));
                    } catch (e) {
                        placeholder.innerHTML = '<div class="hltb-error">数据解析失败</div>';
                    }
                },
                onerror: function () {
                    placeholder.innerHTML = '<div class="hltb-error">请求失败</div>';
                }
            });
        }, 1000); // 等待1秒让页面渲染完成
    }

    // 监听 URL 变化(支持 SPA 路由)
    let lastUrl = location.href;

    // 使用 MutationObserver 监听 DOM 变化来检测页面切换
    const observer = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            console.log('[HLTB] 检测到页面跳转:', location.href);
            // 移除旧的组件
            const oldWidget = document.querySelector('.hltb-widget');
            if (oldWidget) oldWidget.remove();
            // 延迟初始化,等待新页面渲染
            setTimeout(init, 500);
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // 监听 popstate 事件(浏览器前进/后退)
    window.addEventListener('popstate', () => {
        console.log('[HLTB] popstate 事件触发');
        const oldWidget = document.querySelector('.hltb-widget');
        if (oldWidget) oldWidget.remove();
        setTimeout(init, 500);
    });

    // 拦截 pushState 和 replaceState
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = function (...args) {
        originalPushState.apply(this, args);
        console.log('[HLTB] pushState 触发');
        const oldWidget = document.querySelector('.hltb-widget');
        if (oldWidget) oldWidget.remove();
        setTimeout(init, 500);
    };

    history.replaceState = function (...args) {
        originalReplaceState.apply(this, args);
        console.log('[HLTB] replaceState 触发');
        const oldWidget = document.querySelector('.hltb-widget');
        if (oldWidget) oldWidget.remove();
        setTimeout(init, 500);
    };

    // 初始加载
    if (document.readyState === 'complete') init();
    else window.addEventListener('load', init);
})();