Greasy Fork

Greasy Fork is available in English.

小说朗读

小说阅读,朗读

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         小说朗读
// @namespace    http://tampermonkey.net/
// @version      2.1.1
// @description  小说阅读,朗读
// @author       FHT
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM_addElement
// @grant        GM_addStyle
// @grant        unsafeWindow
// @license MIT
// @exclude      https://www.hujuge.com/*/index.html
// @match        https://www.hujuge.com/*/*html
// @match        https://www.qidian.com/chapter/*/*/
// @match        https://www.douyinxs.com/bqg/*/*.html
// ==/UserScript==

(function() {
    'use strict';
    const rule = [
        {
            url: '域名',
            nextSelector: '下一页',
            prevSelector: '上一页',
            indexSelector: '目录',
            titleSelector: '章节名',
            bookTitleSelector: '书名',
            contentSelector: '正文',
            contentReplace: [['去广告正则', ""],
                             []
                            ]
        },{
            url: 'www.hujuge.com',
            nextSelector: '#next_url',
            prevSelector: '#prev_url',
            indexSelector: '#info_url',
            titleSelector: '.title',
            bookTitleSelector: '.layout-tit>a:nth-of-type(2)',
            contentSelector: '#content',
            contentReplace: [
                ['/\n/g', ' '],
                [/章节错误.*/g,'']]
        }, {
            url: 'www.douyinxs.com',
            nextSelector: '#next',
            prevSelector: '#prev',
            indexSelector: '.bottem1 a:nth-child(2)',
            titleSelector: '.bookname h1',
            bookTitleSelector: '.con_top>a:nth-of-type(3)',
            contentSelector: '#content',
            contentReplace: [['/\n/g', ' ']]
        },{
            url: 'www.qidian.com',
            nextSelector: '.nav-btn-group>a:nth-last-of-type(1)',
            prevSelector: '.nav-btn-group>a:nth-of-type(1)',
            indexSelector: '.nav-btn-group>a:nth-of-type(2)',
            titleSelector: '.chapter-wrapper h1',
            bookTitleSelector: '#r-breadcrumbs>a:nth-of-type(4)',
            contentSelector: '.content',
            contentReplace: [[/\s\s/g, '\n'],[/<span.class=.review-count.>\d+<\/span>/g, '']
                            ]
        },
    ]

    GM_addElement('script', {
        src: 'https://cdn.staticfile.net/vue/3.3.4/vue.global.min.js',
        type: 'text/javascript'
    });
    GM_addElement('script', {
        src: 'https://cdn.staticfile.net/axios/1.6.5/axios.min.js',
        type: 'text/javascript'
    });
    window.onload = () => {
        let body = document.querySelector('body')
        let htmlData = body.innerHTML
        const synth = window.speechSynthesis;
        synth.cancel()
        const utterThis = new SpeechSynthesisUtterance();
        body.innerHTML = ''
        GM_addElement(body, 'div', {
            id:'app'
        });
        const { createApp } = Vue
        createApp({
            data() {
                return {
                    bookData: {
                        bookName: null,             //书名
                        chapter: [],                //章节
                        content: [],                //正文
                        next: null,                 //下一章url
                        prev: null,                 //目录url
                        menu: null,                 //上一章url
                    },
                    voicesData: {
                        voicesList: [],             //语音列表
                        voicesIndex: 5,             //默认语音
                    },
                    readData: {
                        readIndex: 0,               //当前章节下标
                    },
                    speakData: {
                        speakState: 0,              //语音是否存在
                        speakingState: 1,           //语音暂停播放
                        speakIndex: 0,              //语音播放的章节下标
                        focusText: null             //是否有选中文字
                    },
                    data: '',                       //隐藏div数据
                    state: 1,                       //监听防抖
                }
            },
            created() {
                this.data = htmlData
            },
            mounted() {
                this.setData()
                //获取朗读引擎
                const _this = this
                synth.onvoiceschanged = function () {
                    _this.voicesData.voicesList = []
                    let arr = synth.getVoices()
                    for (let i = 0; i < arr.length; i++) {
                        if (arr[i].lang == 'zh-CN') {
                            _this.voicesData.voicesList.push(arr[i])
                        }
                    }
                }
                utterThis.onend = (event) => {
                    this.speakData.focusText = null
                    let tag = document.querySelector('.right_' + this.speakData.speakIndex)
                    tag.innerHTML = tag.innerHTML.replace(/<\/?span.*?>/g, '')
                    this.speakData.speakIndex++
                    if (this.speakData.speakIndex < this.bookData.chapter.length) {
                        tag = document.querySelector('.right_' + this.speakData.speakIndex)
                        tag.scrollIntoView(true)
                        utterThis.voice = this.voicesData.voicesList[this.voicesData.voicesIndex]
                        utterThis.text = tag.innerText
                        synth.speak(utterThis)
                    } else {
                        axios({
                            url: this.bookData.next,
                            method: 'get'
                        }).then(res => {
                            this.data = /<body[^>]*>([\s\S]*)<\/body>/.exec(res.data)[1]
                            this.setData()
                            setTimeout(() => {
                                tag = document.querySelector('.right_' + _this.speakData.speakIndex)
                                tag.scrollIntoView(true)
                                utterThis.voice = _this.voicesData.voicesList[_this.voicesData.voicesIndex]
                                utterThis.text = tag.innerText
                                synth.speak(utterThis)
                            }, 1);
                        })
                    }
                };
                utterThis.onboundary = (event) => {
                    let div = document.querySelector('.right_' + this.speakData.speakIndex)
                    let str
                    let read_text = []
                    if (this.speakData.focusText) {
                        let t1 = div.innerText.split(this.speakData.focusText)[0]
                        let t2 = this.speakData.focusText + div.innerText.split(this.speakData.focusText)[1]
                        str = t2.substr(event.charIndex, event.charLength)
                        read_text[0] = t2.slice(0, event.charIndex)
                        read_text[1] = t2.slice(event.charIndex)
                        div.innerHTML = t1 + read_text[0] + read_text[1].replace(str, "<span class ='activ'>" + str + "</span>")
                    } else {
                        str = div.innerText.substr(event.charIndex, event.charLength)
                        read_text[0] = div.innerText.substr(0, event.charIndex)
                        read_text[1] = div.innerText.substr(event.charIndex)
                        div.innerHTML = read_text[0] + read_text[1].replace(str, "<span class ='activ'>" + str + "</span>")
                    }
                    if (document.querySelector('.activ').offsetTop > document.querySelector('.right').scrollTop + document.querySelector('.right').clientHeight) {
                        document.querySelector('.right').scrollTop = document.querySelector('.activ').offsetTop - 50
                    }
                }
                //监听滚动条
                const right = document.querySelector('.right')
                right.addEventListener('scroll', () => {
                    if (right.scrollHeight - right.scrollTop - right.clientHeight <= 400) {
                        this.state++
                        if (this.state == 2) {
                            this.getData({ url: this.bookData.next, method: 'get' })
                        }
                    }
                    //判断当前页面在第几章
                    switch (document.querySelectorAll('.right>div').length) {
                        case 1:
                            this.readData.readIndex = 0;
                            break;
                        case 2:
                            right.scrollTop >= document.querySelectorAll('.right>div')[1].offsetTop ? this.readData.readIndex = 1 : this.readData.readIndex = 0
                            break;
                        default:
                            for (let index = 0; index < document.querySelectorAll('.right>div').length - 1; index++) {
                                if (document.querySelectorAll('.right>div')[index].offsetTop <= right.scrollTop && right.scrollTop < document.querySelectorAll('.right>div')[index + 1].offsetTop) {
                                    this.readData.readIndex = index
                                    break
                                } else {
                                    right.scrollTop < document.querySelectorAll('.right>div')[1].offsetTop ? this.readData.readIndex = 0 : this.readData.readIndex = index + 1
                                }
                            }
                            break;
                    }
                })
            },
            methods: {
                selectChange() {
                    this.voicesData.voicesIndex = document.querySelector('#voiceSelect').value
                },
                // 语音功能按钮
                play() {
                    this.speakData.speakIndex = this.readData.readIndex
                    utterThis.voice = this.voicesData.voicesList[this.voicesData.voicesIndex]
                    if (window.getSelection().toString()) {
                        this.speakData.focusText = window.getSelection().toString()
                        utterThis.text = this.speakData.focusText + document.querySelector('.right_' + this.speakData.speakIndex).innerText.split(this.speakData.focusText)[1]
                    } else {
                        utterThis.text = document.querySelector('.right_' + this.speakData.speakIndex).innerText
                    }
                    synth.speak(utterThis)
                    this.speakData.speakState = synth.speaking
                },
                del() {
                    synth.cancel()
                    this.speakData.speakState = synth.speaking
                    this.speakData.speakingState = !synth.paused

                },
                suspend() {
                    if (synth.speaking) {
                        synth.pause()
                        this.speakData.speakingState = synth.paused
                    } else (
                        alert('没有播放文本')
                    )
                },
                recovery() {
                    if (synth.speaking) {
                        synth.resume()
                        this.speakData.speakingState = !synth.paused
                    }
                },
                // 设置数据
                setData() {
                    rule.forEach(element => {
                        if (window.location.host == element.url) {
                            this.$nextTick(() => {
                                const name = document.querySelector(element.bookTitleSelector)
                                const chapter = document.querySelector(element.titleSelector)
                                const menu = document.querySelector(element.indexSelector)
                                const prev = document.querySelector(element.prevSelector)
                                const next = document.querySelector(element.nextSelector)
                                const content = document.querySelector(element.contentSelector)
                                element.contentReplace.forEach(e => {
                                    content.innerHTML = content.innerHTML.replace(e[0], e[1])
                                })
                                this.bookData.bookName = name ? name.innerHTML : '未找到书名'
                                this.bookData.chapter.push({ msg: chapter ? chapter.innerText : '未找到章节名', src: this.bookData.next || window.location.href })
                                this.bookData.menu = menu ? menu.href : '未找到'
                                this.bookData.prev = this.bookData.prev ? this.bookData.prev : prev.href
                                this.bookData.next = next ? next.href : '未找到'
                                this.bookData.content.push(content ? content.innerText : '未找到内容')
                                this.data = ''
                            })
                        }
                    });
                },
                // 请求数据
                getData(params) {
                    axios({
                        url: params.url,
                        method: params.method
                    }).then(res => {
                        this.state = 1
                        this.data = /<body[^>]*>([\s\S]*)<\/body>/.exec(res.data)[1]
                        this.setData()
                    })
                },
                //点击章节列表
                click_left_chapter(params) {
                    this.readData.readIndex = params
                    const tag = '.right > div:nth-of-type(' + (params + 1) + ')'
                    document.querySelector(tag).scrollIntoView(true)
                },
                //根据标签内容选择标签
                tagContains(params) {
                    const arr = document.querySelectorAll(params.tag)
                    for (let index = 0; index < arr.length; index++) {
                        const element = arr[index];
                        if (element.innerText == params.msg) {
                            return element
                        }
                    }
                }
            },
            watch: {
                'readData.readIndex'() {
                    window.history.replaceState('', '', this.bookData.chapter[this.readData.readIndex].src)
                }
            },
            template: `
            <div v-show="0" v-html="data"></div>
            <div class="left">
                <div class="left_bookname">{{bookData.bookName}}</div>
                <div class="left_select">
                    <a :href = 'bookData.prev'>上一章</a>
                    <a :href = 'bookData.menu'>目录</a>
                    <a :href = 'bookData.next'>下一章</a>
                </div>
                <div class="left_chapter">
                    <div v-for="i,j in bookData.chapter"
                         :class = '[{ li_activ: j==readData.readIndex },"left_"+j]'
                         @click='click_left_chapter(j)'>{{ i.msg }}</div>
                </div>
            </div>
            <div class="right">
                <div v-for="i,j in bookData.content">
                    <div class="tit">{{ bookData.chapter[j].msg }}</div>
                    <div v-html="i" :class = '"right_"+j'></div>
                </div>
            </div>
            <div class="speak">
                <select name="" id="voiceSelect"  v-if="!speakData.speakState" @change = selectChange() >
                    <option v-for="i,j in voicesData.voicesList" :value=j :selected = 'voicesData.voicesIndex==j?1:0'>{{ i.name }}</option>
                </select>
                <button v-if="speakData.speakState" @click = del()>终止</button>
                <button v-else  @click = play()>播放</button>
                <button v-if="speakData.speakingState" @click = suspend()>暂停</button>
                <button v-else @click = recovery()>恢复</button>
            </div>
            `,
        }).mount('#app')
    }
    // css
    const css = ` * {
            margin: 0;
            padding: 0;
        }
        #app {
            width: 100vw;
            height: 100vh;
            display: flex;
            overflow: hidden;
            background: url(https://qidian.gtimg.com/qd/images/read.qidian.com/theme/theme_1_bg_2x.0.3.png);
        }
        .left {
            width: 250px;
            height: 100%;
            overflow: hidden;
            background: rgb(70, 70, 70);
            color: white;
            font-size: 14px;
            cursor: pointer;
            padding: 8px;

        }
        .left a{

            color: white;
        }
        .left_bookname {
            text-align: center;
            font-size: 24px;
            font-weight: bold;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            padding: 10px 10px 15px;
            border-bottom: 2px solid black;
        }
        .left_select {
            display: flex;
            justify-content: space-evenly;
            padding: 10px;
            border-bottom: 1px solid black;
        }
        .left_chapter {
            overflow: auto;
            height: 85%;
        }
        .left_chapter div {
            padding: 10px;
            border-bottom: 1px solid black;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .right {
            overflow: auto;
            width: 100%;
            height: 100%;
            min-width: 60%;
            font-size: 26px;
            line-height: 50px;
            white-space: pre-line !important;
            user-select: text !important;
        }
        .tit {
            text-align: center;
            margin: 10px 80px;
            padding: 10px;
            border-bottom: solid 1px rgb(134, 124, 124);
            font-size: 32px;
        }
        .right div {
            margin: 0 100px;
            letter-spacing: 4px;
        }
        .speak {
            position: fixed;
            top: 10px;
            right: 30px;
        }
        .li_activ {
            background: #000;
        }

        .activ {
            color: red;
        } `
    GM_addStyle(css)
})();