huggingface与hf-mirror相互跳转与modelscope跳转

在 Hugging Face、hf-mirror.com 和 modelscope.cn 的页面添加相互跳转链接,支持 Model card, Files, Discussions 精准映射,并优化 SPA 稳定性。

// ==UserScript==
// @name        huggingface与hf-mirror相互跳转与modelscope跳转
// @namespace   http://tampermonkey.net/
// @version     2.1
// @description 在 Hugging Face、hf-mirror.com 和 modelscope.cn 的页面添加相互跳转链接,支持 Model card, Files, Discussions 精准映射,并优化 SPA 稳定性。
// @author      flyway + Gemini
// @match       https://huggingface.co/*
// @match       https://hf-mirror.com/*
// @match       https://modelscope.cn/*
// @grant       none
// @license     MIT
// ==/UserScript==

(function() {
    'use strict';

    var currentUrl = window.location.href;
    var isMirrorPage = currentUrl.includes('hf-mirror.com');
    var isModelScopePage = currentUrl.includes('modelscope.cn');
    var isHuggingFacePage = currentUrl.includes('huggingface.co');

    // ModelScope 的 Tab 结构颜色和样式
    const MS_HF_COLOR = 'orange'; // 切换到 HuggingFace
    const MS_HFM_COLOR = 'green';  // 切换到 hf-mirror
    const MS_MODEL_SCOPE_COLOR = '#816DF8'; // 切换到 ModelScope

    // --- 辅助函数:Tab 容器和元素创建 ---

    function getTabContainer() {
        var hfContainer = document.querySelector('div.-mb-px.flex.h-12.items-center.overflow-x-auto.overflow-y-hidden');
        if (hfContainer) return hfContainer;

        var msNav = document.querySelector('div.antd5-tabs-nav');
        if (msNav) {
            return msNav.querySelector('div.antd5-tabs-nav-list');
        }

        return null;
    }

    function createMsJumpTab(text, href, color, className) {
        var tabDiv = document.createElement('div');
        tabDiv.className = 'antd5-tabs-tab ' + className;
        tabDiv.setAttribute('data-node-key', className);

        var tabBtnDiv = document.createElement('div');
        tabBtnDiv.className = 'antd5-tabs-tab-btn';
        tabBtnDiv.setAttribute('role', 'tab');
        tabBtnDiv.setAttribute('aria-selected', 'false');
        tabBtnDiv.setAttribute('tabindex', '0');

        var link = document.createElement('a');
        link.href = href;
        link.target = '_blank';

        var textWrapperDiv = document.createElement('div');
        textWrapperDiv.className = 'modelDetail_tabs_icon';
        textWrapperDiv.textContent = text;
        textWrapperDiv.style.color = color;
        textWrapperDiv.style.fontWeight = 'bold';
        textWrapperDiv.style.padding = '0 10px';

        link.appendChild(textWrapperDiv);
        tabBtnDiv.appendChild(link);
        tabDiv.appendChild(tabBtnDiv);

        return tabDiv;
    }


    // --- 核心 URL 转换逻辑(基于 Tab 路径映射) ---

    /**
     * 将 ModelScope URL 转换为 Hugging Face URL,并精确映射 Tab 路径
     */
    function convertMsToHfUrl(url) {
        var targetUrl = url.replace('modelscope.cn', 'huggingface.co');

        // 1. 移除 /models/ 前缀
        targetUrl = targetUrl.replace(/\/models\//, '/');

        // 2. 移除和替换 ModelScope 的 Tab 路径为 HF 对应的路径
        // 路径映射:/summary -> '' (根目录); /files -> /tree/main; /feedback -> /discussions
        if (targetUrl.includes('/summary')) {
            targetUrl = targetUrl.replace(/\/summary/g, '');
        } else if (targetUrl.includes('/files')) {
            targetUrl = targetUrl.replace(/\/files/g, '/tree/main');
        } else if (targetUrl.includes('/feedback')) {
            targetUrl = targetUrl.replace(/\/feedback/g, '/discussions');
        } else {
            // 如果是模型根目录 (如 /user/repo),则确保是 HF 的根路径 (Model Card)
            var path = new URL(targetUrl).pathname;
            if (path.match(/^\/([^\/]+)\/([^\/]+)\/?$/)) {
                 // 确保链接以 /user/repo 结束,对应 HF 的 Model Card 页面
                 targetUrl = targetUrl.replace(/\/$/, '');
            }
        }

        // 确保 URL 末尾没有重复的斜杠
        targetUrl = targetUrl.replace(/([^:]\/)\/+/g, '$1');

        return targetUrl;
    }

    /**
     * 将 Hugging Face URL 转换为 ModelScope URL,并精确映射 Tab 路径
     */
    function convertHfToMsUrl(url) {
        var targetUrl = url.replace('huggingface.co', 'modelscope.cn').replace('hf-mirror.com', 'modelscope.cn');

        // 1. 在 user/repo 前面添加 /models
        // 匹配模式:(域名/)(user/repo)
        targetUrl = targetUrl.replace(/(modelscope\.cn\/)([^\/]+\/[^\/]+)/, '$1models/$2');

        // 2. 移除和替换 Hugging Face 的 Tab 路径为 ModelScope 对应的路径
        // 路径映射:/discussions -> /feedback; /tree/main -> /files; (根路径/blob/...) -> /summary

        // 移除 HF 分支或文件路径中的 /main 和 /blob
        targetUrl = targetUrl.replace(/\/main\/?$|\/blob\/?$/g, '');

        if (targetUrl.includes('/discussions')) {
            targetUrl = targetUrl.replace(/\/discussions/g, '/feedback');
        } else if (targetUrl.includes('/tree')) {
            targetUrl = targetUrl.replace(/\/tree/g, '/files');
        } else {
            // 如果是 HF 的根路径(Model Card),则转换为 ModelScope 的 /summary
            var path = new URL(targetUrl).pathname;
            // 匹配 /models/user/repo 或 /models/user/repo/ 形式
            if (path.match(/\/models\/([^\/]+)\/([^\/]+)\/?$/)) {
                if (!targetUrl.endsWith('/')) {
                    targetUrl += '/';
                }
                targetUrl += 'summary';
            }
        }

        // 确保 URL 末尾没有重复的斜杠
        targetUrl = targetUrl.replace(/([^:]\/)\/+/g, '$1');

        return targetUrl;
    }

    // --- 主要功能函数:添加 Tab 链接 ---

    function addTabLink() {
        var tabContainer = getTabContainer();
        if (!tabContainer) return;

        var hfLinkClass = 'tab-alternate custom-tab hf-mirror-jump';

        // 1. HuggingFace 页面互跳和到 ModelScope 的跳转
        if (!isModelScopePage) {
            // ... (Hugging Face / hf-mirror 互跳逻辑不变)
            if (!tabContainer.querySelector('.custom-tab.hf-mirror-jump')) {
                var jumpLink = document.createElement('a');
                jumpLink.className = hfLinkClass;
                jumpLink.style.marginLeft = '10px';
                if (isMirrorPage) {
                    jumpLink.href = currentUrl.replace('hf-mirror.com', 'huggingface.co');
                    jumpLink.textContent = '切换到 huggingface 页面';
                    jumpLink.style.color = MS_HF_COLOR;
                } else {
                    jumpLink.href = currentUrl.replace('huggingface.co', 'hf-mirror.com');
                    jumpLink.textContent = '切换到 hf-mirror 页面';
                    jumpLink.style.color = MS_HFM_COLOR;
                }
                tabContainer.appendChild(jumpLink);
            }

            // Hugging Face / hf-mirror 到 ModelScope 的跳转
            if (!tabContainer.querySelector('.custom-tab.modelscope-jump')) {
                var modelscopeLink = document.createElement('a');
                modelscopeLink.className = hfLinkClass + ' modelscope-jump';
                modelscopeLink.style.marginLeft = '10px';

                var targetUrl = convertHfToMsUrl(currentUrl);

                modelscopeLink.href = targetUrl;
                modelscopeLink.textContent = '切换到 modelscope 页面';
                modelscopeLink.style.color = MS_MODEL_SCOPE_COLOR;

                tabContainer.appendChild(modelscopeLink);
            }
        }

        // 2. ModelScope Tab 添加逻辑
        if (isModelScopePage) {

            if (tabContainer.children.length < 2) return;

            // ModelScope 上的 Tab 链接:切换到 HuggingFace
            if (!tabContainer.querySelector('.ms-hf-jump')) {
                var targetUrl = convertMsToHfUrl(currentUrl);

                var hfJumpTab = createMsJumpTab(
                    '切换到 huggingface 页面',
                    targetUrl,
                    MS_HF_COLOR,
                    'ms-hf-jump'
                );
                tabContainer.appendChild(hfJumpTab);
            }

            // ModelScope 上的 Tab 链接:切换到 hf-mirror
            if (!tabContainer.querySelector('.ms-hfm-jump')) {
                // 仅转换到 HF,然后替换域名到 hf-mirror
                var targetUrl = convertMsToHfUrl(currentUrl).replace('huggingface.co', 'hf-mirror.com');

                var hfmJumpTab = createMsJumpTab(
                    '切换到 hf-mirror 页面',
                    targetUrl,
                    MS_HFM_COLOR,
                    'ms-hfm-jump'
                );
                tabContainer.appendChild(hfmJumpTab);
            }
        }
    }

    // --- 稳定化和初始化 ---

    // ... (addBlobLinks 和 addTreeDownloadButtons 逻辑不变,省略)
    function addBlobLinks() {
        var isModelScopePage = window.location.href.includes('modelscope.cn');
        var isMirrorPage = window.location.href.includes('hf-mirror.com');
        if (isModelScopePage) return;
        var messageDiv = document.querySelector('div.p-4.py-8.text-center');
        if (messageDiv && !messageDiv.querySelector('.custom-links')) {
            var newP = document.createElement('p');
            newP.className = 'custom-links';
            newP.style.marginTop = '20px';
            if (isMirrorPage) {
                return;
            } else {
                var downloadLink = document.querySelector('a[href*="/resolve/"]');
                if (downloadLink) {
                    var originalDownloadUrl = downloadLink.href;
                    var urlObj = new URL(originalDownloadUrl);
                    var mirrorDownloadUrl = urlObj.origin.replace('huggingface.co', 'hf-mirror.com') + urlObj.pathname;
                    var downloadLinkMirror = document.createElement('a');
                    downloadLinkMirror.href = mirrorDownloadUrl;
                    downloadLinkMirror.textContent = '使用 hf-mirror 下载';
                    downloadLinkMirror.style.color = 'green';
                    downloadLinkMirror.style.textDecoration = 'underline';
                    downloadLinkMirror.style.marginRight = '10px';
                    downloadLinkMirror.target = '_blank';
                    downloadLinkMirror.rel = 'noopener noreferrer';
                    newP.appendChild(downloadLinkMirror);
                }
            }
            messageDiv.appendChild(newP);
        }
    }

    function addTreeDownloadButtons() {
        var isTreePage = window.location.href.includes('/tree/main');
        var isMirrorPage = window.location.href.includes('hf-mirror.com');
        var isModelScopePage = window.location.href.includes('modelscope.cn');
        if (!isTreePage || isMirrorPage || isModelScopePage) return;
        var fileLinks = document.querySelectorAll('a.group.col-span-9.flex.items-center[href*="/resolve/"]');
        fileLinks.forEach(function(link) {
            if (!link.querySelector('.custom-mirror-download')) {
                var originalHref = link.href;
                var mirrorHref = originalHref.replace('huggingface.co', 'hf-mirror.com').split('?')[0];
                var originalDownloadBtn = link.querySelector('div.group-hover\\:shadow-xs.ml-2.flex.h-5.w-5');
                if (originalDownloadBtn) {
                    var mirrorDownloadBtn = document.createElement('div');
                    mirrorDownloadBtn.className = 'ml-2 flex h-5 w-5 items-center justify-center rounded-sm border text-green-500 hover:bg-gray-50 hover:text-green-800 dark:border-gray-800 dark:hover:bg-gray-800 dark:hover:text-green-300 xl:ml-4 custom-mirror-download';
                    mirrorDownloadBtn.innerHTML = '<a href="' + mirrorHref + '" target="_blank" rel="noopener noreferrer" title="Download from hf-mirror"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="currentColor" d="M26 24v4H6v-4H4v4a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-4zm0-10l-1.41-1.41L17 20.17V2h-2v18.17l-7.59-7.58L6 14l10 10l10-10z"></path></svg></a>';
                    link.insertBefore(mirrorDownloadBtn, originalDownloadBtn.nextSibling);
                }
            }
        });
    }

    var observerThrottle = false;

    // MutationObserver 持续监控 ModelScope Tab 容器的出现和变化
    var observer = new MutationObserver(function(mutations) {
        if (observerThrottle) return;

        var needsUpdate = false;
        mutations.forEach(function(mutation) {
            if (mutation.addedNodes.length > 0) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === 1 && (node.matches('div.antd5-tabs-nav') || node.matches('div.antd5-tabs-nav-list') || node.closest('div.antd5-tabs-nav-list') || node.closest('div.-mb-px'))) {
                        needsUpdate = true;
                        break;
                    }
                }
            }
        });

        if (needsUpdate) {
            observerThrottle = true;
            setTimeout(() => {
                addTabLink();
                addTreeDownloadButtons();
                addBlobLinks();
                observerThrottle = false;
            }, 500);
        }
    });

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

    // 页面首次加载时执行
    window.addEventListener('load', function() {
        addTabLink();
        addTreeDownloadButtons();
        addBlobLinks();
    });

    // 针对 SPA 特性,在 URL 变化时也重新尝试添加
    var lastUrl = currentUrl;
    setInterval(() => {
        if (window.location.href !== lastUrl) {
            lastUrl = window.location.href;
            if (isModelScopePage || isHuggingFacePage || isMirrorPage) {
                addTabLink();
            }
        }
    }, 200);

})();