Greasy Fork

新闻段落转语音

为新闻段落添加语音播放功能,目前支持纽约时报、CNA、新加坡海峡时报

// ==UserScript==
// @name         新闻段落转语音
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  为新闻段落添加语音播放功能,目前支持纽约时报、CNA、新加坡海峡时报
// @author       raymon
// @match        https://*.nytimes.com/*
// @match        https://*.channelnewsasia.com/*
// @match        https://*.straitstimes.com/*
// @icon         https://openmoji.org/data/color/svg/1F50A.svg
// @license         MIT
// ==/UserScript==

(function () {
    'use strict';

    function appendPlayers() {
        const style = document.createElement('style');
        style.textContent = `
            .tts-container {
                background: #ffffff;
                border: 1px solid #e5e7eb;
                border-radius: 8px;
                padding: 12px 16px;
                margin: 12px 0 36px; /* 上边距 12px,下边距 36px */
                box-shadow: 0 1px 3px rgba(0,0,0,0.1);
                transition: all 0.2s ease;
                max-width: 100%;
                box-sizing: border-box;
            }
            .tts-container:hover {
                box-shadow: 0 4px 6px rgba(0,0,0,0.1);
            }
            .tts-button {
                cursor: pointer;
                padding: 8px 20px;
                background: #2563eb;
                color: white;
                border: none;
                border-radius: 6px;
                display: inline-flex;
                align-items: center;
                font-weight: 600;
                font-size: 14px;
                transition: all 0.2s ease;
                min-width: 90px;
                justify-content: center;
            }
            .tts-button:hover {
                background: #1d4ed8;
                transform: translateY(-1px);
            }
            .tts-button:active {
                transform: translateY(0);
            }
            .tts-controls {
                display: flex;
                align-items: center;
                gap: 12px;
                flex: 1;
                min-width: 0;
                flex-wrap: wrap;
            }
            .tts-rate {
                padding: 6px 10px;
                border: 1px solid #e5e7eb;
                border-radius: 6px;
                background: white;
                font-size: 14px;
                cursor: pointer;
                transition: all 0.2s ease;
                width: 80px;
            }
            .tts-rate:hover {
                border-color: #2563eb;
            }
            .tts-rate:focus {
                outline: none;
                border-color: #2563eb;
                box-shadow: 0 0 0 2px rgba(37,99,235,0.2);
            }
            .progress-bar {
                flex: 1;
                height: 8px;
                background: #e5e7eb;
                border-radius: 4px;
                cursor: pointer;
                position: relative;
                margin: 0 12px;
                min-width: 100px;
                z-index: 1;
                transition: all 0.2s ease;
            }
            .progress-bar:hover {
                background: #d1d5db;
            }
            .progress {
                width: 0%;
                height: 100%;
                background: #2563eb;
                border-radius: 4px;
                transition: width 0.1s linear;
                position: absolute;
                top: 0;
                left: 0;
            }
            .progress:hover {
                background: #1d4ed8;
            }
            .time-btn {
                cursor: pointer;
                padding: 6px 12px;
                background: white;
                border: 1px solid #e5e7eb;
                border-radius: 6px;
                font-size: 14px;
                font-weight: 500;
                color: #374151;
                transition: all 0.2s ease;
                min-width: 50px;
                text-align: center;
            }
            .time-btn:hover {
                background: #f3f4f6;
                border-color: #d1d5db;
                transform: translateY(-1px);
            }
            .time-btn:active {
                transform: translateY(0);
            }
            .controls-wrapper {
                display: flex;
                align-items: center;
                margin-top: 0;
                width: 100%;
                gap: 12px;
                flex-wrap: wrap;
            }
            @media (max-width: 768px) {
                .controls-wrapper {
                    flex-direction: column;
                    align-items: stretch;
                }
                .tts-controls {
                    margin-left: 0;
                    margin-top: 12px;
                }
                .progress-bar {
                    margin: 8px 0;
                }
            }
        `;
        document.head.appendChild(style);

        const synth = window.speechSynthesis;
        let paragraphs = [];

        if (window.location.hostname.includes('nytimes.com')) {
            paragraphs = document.querySelectorAll('section[name="articleBody"] p');
        } else if (window.location.hostname.includes('channelnewsasia.com')) {
            paragraphs = document.querySelectorAll('section[data-title="Content"] p');
        } else if (window.location.hostname.includes('straitstimes.com')) {
            const snapshot = document.evaluate(
                '/html/body/div[5]/div[1]/main/div[1]/article/section[2]//p',
                document,
                null,
                XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
                null
            );
            for (let i = 0; i < snapshot.snapshotLength; i++) {
                paragraphs.push(snapshot.snapshotItem(i));
            }
        }

        function createUtterance(text) {
            const utterance = new SpeechSynthesisUtterance(text);
            utterance.lang = 'en-US';
            utterance.rate = 1.0;
            utterance.pitch = 1.0;
            return utterance;
        }

        paragraphs.forEach((paragraph) => {
            const container = document.createElement('div');
            container.className = 'tts-container';

            const controlsWrapper = document.createElement('div');
            controlsWrapper.className = 'controls-wrapper';

            const button = document.createElement('button');
            button.className = 'tts-button';
            button.innerHTML = 'Play';

            const controls = document.createElement('div');
            controls.className = 'tts-controls';

            const rateLabel = document.createElement('span');
            rateLabel.textContent = 'Speed';
            rateLabel.style.fontWeight = '500';
            rateLabel.style.color = '#374151';

            const rateControl = document.createElement('select');
            rateControl.className = 'tts-rate';
            [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].forEach(rate => {
                const option = document.createElement('option');
                option.value = rate;
                option.text = `${rate}x`;
                if (rate === 1.0) option.selected = true;
                rateControl.appendChild(option);
            });

            const progressBar = document.createElement('div');
            progressBar.className = 'progress-bar';
            const progress = document.createElement('div');
            progress.className = 'progress';
            progressBar.appendChild(progress);

            const backwardBtn = document.createElement('button');
            backwardBtn.className = 'time-btn';
            backwardBtn.textContent = '-3s';

            const forwardBtn = document.createElement('button');
            forwardBtn.className = 'time-btn';
            forwardBtn.textContent = '+3s';

            controls.append(rateLabel, rateControl, backwardBtn, progressBar, forwardBtn);
            controlsWrapper.append(button, controls);
            container.appendChild(controlsWrapper);

            let utterance = createUtterance(paragraph.textContent);
            let isPlaying = false;
            let progressInterval, duration, startTime;
            let currentPosition = 0, pausedPosition = 0;

            function stopSpeaking() {
                synth.cancel();
                isPlaying = false;
                button.innerHTML = 'Play';
                clearInterval(progressInterval);
                pausedPosition = currentPosition;
            }

            function startSpeaking(startPosition = 0) {
                utterance = createUtterance(paragraph.textContent);
                utterance.rate = parseFloat(rateControl.value);

                if (startPosition > 0) {
                    const words = paragraph.textContent.split(' ');
                    const startIndex = Math.floor(words.length * (startPosition / 100));
                    utterance.text = words.slice(startIndex).join(' ');
                }

                progress.style.width = `${startPosition}%`;
                currentPosition = startPosition;
                startTime = Date.now();
                duration = (utterance.text.length / utterance.rate) * 50;

                progressInterval = setInterval(() => {
                    if (!isPlaying) return;
                    const elapsed = Date.now() - startTime;
                    currentPosition = Math.min(startPosition + (elapsed / duration) * (100 - startPosition), 100);
                    progress.style.width = `${currentPosition}%`;
                }, 50);

                synth.speak(utterance);
                isPlaying = true;
                button.innerHTML = 'Pause';
            }

            utterance.onend = () => {
                isPlaying = false;
                button.innerHTML = 'Play';
                clearInterval(progressInterval);
                progress.style.width = '100%';
                currentPosition = 100;
                pausedPosition = 0;
                setTimeout(() => {
                    if (!isPlaying) {
                        progress.style.width = '0%';
                        currentPosition = 0;
                    }
                }, 500);
            };

            button.addEventListener('click', () => {
                if (isPlaying) stopSpeaking();
                else startSpeaking(pausedPosition);
            });

            rateControl.addEventListener('change', () => {
                if (isPlaying) {
                    stopSpeaking();
                    startSpeaking(currentPosition);
                }
            });

            backwardBtn.addEventListener('click', () => {
                const timeAdj = (-3 * 1000) / duration * 100;
                stopSpeaking();
                startSpeaking(Math.max(0, currentPosition + timeAdj));
            });

            forwardBtn.addEventListener('click', () => {
                const timeAdj = (3 * 1000) / duration * 100;
                stopSpeaking();
                startSpeaking(Math.min(100, currentPosition + timeAdj));
            });

            progressBar.addEventListener('click', (e) => {
                const rect = progressBar.getBoundingClientRect();
                const pos = ((e.clientX - rect.left) / rect.width) * 100;
                stopSpeaking();
                startSpeaking(pos);
            });

            // 插入播放器到段落后面
            paragraph.parentNode.insertBefore(container, paragraph.nextSibling);
        });
    }

    // 延迟加载,确保页面内容已经渲染完毕
    setTimeout(appendPlayers, 1000);
})();