天凤牌谱链接提取器

实时获取并累计牌谱链接

// ==UserScript==
// @name         天凤牌谱链接提取器
// @namespace    http://tampermonkey.net/
// @version      0.6.1
// @description  实时获取并累计牌谱链接
// @author       馒头卡
// @match        *://tenhou.net/3/*
// @match        *://tenhou.net/4/*
// @match        *://nodocchi.moe/tenhoulog/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 全局配置
    const CONFIG = {
        STYLES: {
            panel: {
                position: 'fixed',
                top: '20px',
                right: '20px',
                background: 'white',
                padding: '15px',
                border: '2px solid #ccc',
                borderRadius: '5px',
                zIndex: '9999',
                maxHeight: '80vh',
                overflowY: 'auto',
                boxShadow: '0 0 10px rgba(0,0,0,0.2)',
                minWidth: '450px'
            },
            title: {
                margin: '0 0 10px',
                fontSize: '16px',
                fontWeight: 'bold',
                color: '#333'
            },
            status: {
                margin: '5px 0',
                fontSize: '13px',
                color: '#666'
            },
            textarea: {
                width: '100%',
                height: '200px',
                margin: '10px 0',
                padding: '8px',
                border: '1px solid #ddd',
                borderRadius: '4px',
                fontFamily: 'monospace',
                resize: 'vertical',
                boxSizing: 'border-box'
            },
            button: {
                padding: '8px 15px',
                cursor: 'pointer',
                border: 'none',
                borderRadius: '4px',
                transition: 'all 0.2s ease',
                flex: 1
            }
        },

        SELECTORS: {
            'nodocchi.moe': {
                '/tenhoulog/': {
                    scrollContainer: null,
                    selector: 'td.td_rule > a[href^="http://tenhou.net/0/?log="]',
                    transform: link => link.replace(/&/g, '&'),
                    scrollConfig: {
                        step: 1500,
                    },
                }
            },
            'tenhou.net': {
                '/3/': {
                    scrollContainer: '#hlist',
                    selector: '#hlist > div > a[href^="https://tenhou.net/0/"]',
                    scrollConfig: {
                        step: 600,
                    },
                },
                '/4/': {
                    scrollContainer: '#c32',
                    selector: '#c32 > div > a.s9.s7.bt3',
                    scrollConfig: {
                        step: 600,
                    },
                }
            }
        },

        SCROLL: {
            step: 800,
            interval: 1000,
            timeout: 120000,
            retryLimit: 3
        },

        COLORS: {
            success: '#4CAF50',
            error: '#f44336',
            info: '#2196F3',
            secondary: '#9C27B0'
        }
    };

    const createElement = (tag, styles = {}, attributes = {}) => {
        const el = document.createElement(tag);
        Object.assign(el.style, styles);
        Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v));
        return el;
    };

    class LinkExtractor {
        constructor() {
            this.isProcessing = false;
            this.collectedLinks = new Set(); // 新增:用于存储去重后的链接
            this.initPanel();
        }

        initPanel() {
            document.getElementById('th-link-panel')?.remove();

            this.panel = createElement('div', CONFIG.STYLES.panel, { id: 'th-link-panel' });
            this.title = createElement('div', CONFIG.STYLES.title);
            this.statusBar = createElement('div', CONFIG.STYLES.status);
            this.textarea = createElement('textarea', CONFIG.STYLES.textarea, { readonly: true });

            const btnContainer = createElement('div', {
                display: 'flex',
                gap: '10px',
                marginTop: '10px'
            });

            const buttons = [
                {
                    text: '开始获取',
                    color: CONFIG.COLORS.info,
                    action: () => this.startProcess()
                },
                {
                    text: '复制链接',
                    color: CONFIG.COLORS.success,
                    action: () => this.copyLinks()
                },
                {
                    text: '下载牌谱',
                    color: CONFIG.COLORS.secondary,
                    action: () => this.downloadLinks()
                }
            ];

            this.controlButtons = buttons.map(config => {
                const btn = createElement('button',
                    { ...CONFIG.STYLES.button, background: config.color },
                    { type: 'button' }
                );
                btn.textContent = config.text;
                btn.addEventListener('click', config.action);
                return btn;
            });

            btnContainer.append(...this.controlButtons);
            this.panel.append(
                this.title,
                this.statusBar,
                this.textarea,
                btnContainer
            );
            document.body.appendChild(this.panel);
        }

        showStatus(text, color = CONFIG.COLORS.info) {
            this.statusBar.textContent = text;
            this.statusBar.style.color = color;
        }

        async startProcess() {
            if (this.isProcessing) return;

            try {
                this.toggleProcessing(true);
                const pageConfig = this.getCurrentPageConfig();
                const scrollContainer = pageConfig.container;
                const scrollConfig = { ...CONFIG.SCROLL, ...pageConfig.scrollConfig };

                await this.autoScroll(scrollContainer, scrollConfig);
                this.showStatus(`完成!共获取 ${this.collectedLinks.size} 个链接`, CONFIG.COLORS.success);
            } catch (error) {
                this.showStatus(`操作失败: ${error.message}`, CONFIG.COLORS.error);
            } finally {
                this.toggleProcessing(false);
            }
        }

        autoScroll(scrollContainer, scrollConfig) {
            return new Promise((resolve) => {
                const isDocumentContainer = scrollContainer === document.documentElement;
                let lastScrollPos = isDocumentContainer ? window.pageYOffset : scrollContainer.scrollTop;
                let retryCount = 0;
                const startTime = Date.now();

                const getScrollPosition = () => {
                    return isDocumentContainer ?
                        window.pageYOffset + window.innerHeight :
                        scrollContainer.scrollTop + scrollContainer.clientHeight;
                };
                // 新增:实时提取链接的方法
                const processLinks = () => {
                    const newLinks = this.extractLinks(this.getCurrentPageConfig().selectorConfig);
                    const prevSize = this.collectedLinks.size;
                    newLinks.forEach(link => this.collectedLinks.add(link));
                    
                    if (this.collectedLinks.size > prevSize) {
                        this.updateUI();
                        // this.showStatus(`已累计发现 ${this.collectedLinks.size} 个链接`, CONFIG.COLORS.info);
                    }
                };

                const scrollStep = () => {
                    if (Date.now() - startTime > scrollConfig.timeout) {
                        this.showStatus(`滚动超时(${scrollConfig.timeout}ms)`, CONFIG.COLORS.error);
                        resolve();
                        return;
                    }
                    
                    processLinks(); // 每次滚动前先处理现有链接

                    const currentPosition = getScrollPosition();
                    const totalHeight = isDocumentContainer ?
                        document.documentElement.scrollHeight :
                        scrollContainer.scrollHeight;

                    if (currentPosition >= totalHeight - 10) {
                        if (totalHeight === (isDocumentContainer ? lastScrollPos + window.innerHeight : lastScrollPos)) {
                            if (++retryCount >= scrollConfig.retryLimit) {
                                resolve();
                                return;
                            }
                        } else {
                            retryCount = 0;
                            lastScrollPos = isDocumentContainer ? totalHeight - window.innerHeight : totalHeight;
                        }
                    }

                    if (isDocumentContainer) {
                        window.scrollBy(0, scrollConfig.step);
                    } else {
                        scrollContainer.scrollTop += scrollConfig.step;
                    }

                    setTimeout(scrollStep, scrollConfig.interval);
                };

                this.showStatus('采集中,请勿操作页面...');
                processLinks(); // 初始加载时处理一次
                scrollStep();
            });
        }

        getCurrentPageConfig() {
            const { hostname, pathname } = window.location;

            for (const [domain, domainConfig] of Object.entries(CONFIG.SELECTORS)) {
                if (!hostname.includes(domain)) continue;

                const pathConfig = this.findPathConfig(domainConfig, pathname);
                if (pathConfig) {
                    let container = document.documentElement;
                    if (pathConfig.scrollContainer) {
                        container = document.querySelector(pathConfig.scrollContainer) || container;
                    }

                    return {
                        container: container,
                        scrollConfig: pathConfig.scrollConfig || {},
                        selectorConfig: pathConfig
                    };
                }
            }

            return {
                container: document.documentElement,
                scrollConfig: {},
                selectorConfig: null
            };
        }

        toggleProcessing(processing) {
            this.isProcessing = processing;
            this.controlButtons.forEach(btn => btn.disabled = processing);
        }

        extractLinks(selectorConfig) {
            if (!selectorConfig) return [];
            const elements = document.querySelectorAll(selectorConfig.selector);
            let links = Array.from(elements).map(a => a.href);
            return selectorConfig.transform ? links.map(selectorConfig.transform) : links;
        }

        updateUI(links) {
            this.title.textContent = `累计发现 ${this.collectedLinks.size} 个牌谱链接`;
            this.textarea.value = Array.from(this.collectedLinks).join('\n');
        }

        copyLinks() {
            this.textarea.value = Array.from(this.collectedLinks).join('\n'); // 确保复制最新数据
            document.execCommand('copy');
            this.showStatus('链接已复制到剪贴板', CONFIG.COLORS.success);
        }

        downloadLinks() {
            const content = Array.from(this.collectedLinks).join('\n');
            if (!content) return;

            const blob = new Blob([content], { type: 'text/plain' });

            const url = URL.createObjectURL(blob);
            const a = createElement('a', {}, {
                href: url,
                download: `牌谱-${new Date().toLocaleString().replace(/[/:]/g, '-')}.txt`
            });

            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
                        
            this.showStatus('文件下载已开始', CONFIG.COLORS.secondary);
        }

        findPathConfig(config, currentPath) {
            for (const [pathPrefix, pathConfig] of Object.entries(config)) {
                if (currentPath.startsWith(pathPrefix)) {
                    return pathConfig;
                }
            }
            return null;
        }
    }

    window.addEventListener('load', () => new LinkExtractor());
})();