Greasy Fork

Greasy Fork is available in English.

Bangumi评分脚本・改

改造自 http://bangumi.tv/group/topic/345087

当前为 2018-06-20 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Bangumi Evaluation
// @name:zh-CN   Bangumi评分脚本・改
// @namespace    https://github.com/ipcjs/
// @version      1.1.5
// @description  Bangumi Evaluation Script
// @description:zh-CN 改造自 http://bangumi.tv/group/topic/345087
// @author       ipcjs
// @include      *://bgm.tv/ep/*
// @include      *://bgm.tv/character/*
// @include      *://bgm.tv/blog/*
// @include      *://bgm.tv/*/topic/*
// @include      *://bangumi.tv/ep/*
// @include      *://bangumi.tv/character/*
// @include      *://bangumi.tv/blogep/*
// @include      *://bangumi.tv/*/topic/*
// @include      *://chii.in/ep/*
// @include      *://chii.in/characterep/*
// @include      *://chii.in/blog/*
// @include      *://chii.in/*/topic/*
// @compatible   chrome
// @compatible   firefox
// @grant        none
// @run-at       document-end
// ==/UserScript==

'use strict'
const script = {
    name: '评论区投票助手',
    handler: window.GM_info ? window.GM_info.scriptHandler : '组件'
}
if (!window.beuj_running) {
    window.beuj_running = true
} else {
    console.log(`${script.name}(${script.handler})已经在运行了~~`)
    return
}

// type, props, children
// type, props, innerHTML
// 'text', text
const util_ui_element_creator = (type, props, children) => {
    let elem = null;
    if (type === "text") {
        return document.createTextNode(props);
    } else {
        elem = document.createElement(type);
    }
    for (let n in props) {
        if (n === "style") {
            for (let x in props.style) {
                elem.style[x] = props.style[x];
            }
        } else if (n === "className") {
            elem.className = props[n];
        } else if (n === "event") {
            for (let x in props.event) {
                elem.addEventListener(x, props.event[x]);
            }
        } else {
            elem.setAttribute(n, props[n]);
        }
    }
    if (children) {
        if (typeof children === 'string') {
            elem.innerHTML = children;
        } else {
            for (let i = 0; i < children.length; i++) {
                if (children[i] != null)
                    elem.appendChild(children[i]);
            }
        }
    }
    return elem;
}
const _ = util_ui_element_creator
const util_stringify = (item) => {
    if (typeof item === 'object') {
        try {
            return JSON.stringify(item)
        } catch (e) {
            console.debug(e)
            return item.toString()
        }
    } else {
        return item
    }
}
const util_ui_alert = function (message, callback, delay) {
    delay === undefined && (delay = 500)
    setTimeout(() => {
        if (callback) {
            if (window.confirm(message)) {
                callback()
            }
        } else {
            alert(message)
        }
    }, delay)
}
const addStyle = (css) => {
    document.head.appendChild(_('style', {}, [_('text', css)]))
}
const ajax = (...args) => new Promise((resolve, reject) => $.ajax(...args).done(resolve).fail(reject))

// language=CSS
addStyle(`
    .inputButton {
        background-color: #F09199;
        color: #fff;
        cursor: pointer;
        font-family: lucida grande, tahoma, verdana, arial, sans-serif;
        font-size: 11px;
        padding: 1px 3px;
        text-decoration: none;
    }

    .forum_category {
        background-color: #F09199;
        color: #fff;
        font-weight: 700;
        padding: 3px;
    }

    .vote_container {
        background-color: #e1e7f5
    }

    .forum_boardrow1 {
        background-color: #fff;
        border-color: #ebebeb;
        border-style: solid;
        border-width: 0;
        padding: 6px 4px;
        vertical-align: top
    }

    .form-option {
        float: right;
        color: #AAA;
        font-size: 12px;
    }
    .beuj-hidden {
        display: none;
    }
    .beuj-float-right {
        float: right;
    }
`)
const TRUE = 'Y'
const FALSE = ''
const HOME_URL_PATH = '/group/topic/345237'
const HOME_URL = 'https://bgm.tv' + HOME_URL_PATH
const INSTALL_URL = 'http://greasyfork.icu/zh-CN/scripts/39144'
const SCORE_REGEX = /^\s*([+-]\d+)(\W[^]*)?$/ // 以数字开头的评论
localStorage.beuj_need_mask === undefined && (localStorage.beuj_need_mask = FALSE)
localStorage.beuj_need_suffix === undefined && (localStorage.beuj_need_suffix = TRUE)
localStorage.beuj_flag_to_watched === undefined && (localStorage.beuj_flag_to_watched = TRUE)
localStorage.beuj_show_form_in_ep === undefined && (localStorage.beuj_show_form_in_ep = TRUE)
localStorage.beuj_show_form_in_other === undefined && (localStorage.beuj_show_form_in_other = FALSE)
let beuj_only_one_suffix = TRUE // 一个页面最多放一个小尾巴
const COMMENTS_DEFAULT = '力荐 不错 一般 不喜欢 垃圾'
let commentTemplates = (localStorage.beuj_comment_templates || COMMENTS_DEFAULT).split(' ')

const is_login = !document.querySelector('div.guest')
const getUserId = () => {
    const $avatar = document.querySelector('div.idBadgerNeue a.avatar')
    return $avatar && $avatar.href.split('/')[4] || ''
}
const getGh = () => {
    let $formhash = document.querySelector('#new_comment #ReplyForm > input[name=formhash]')
    return $formhash.value
}
const util_page = {
    ep: () => location.pathname.match(/^\/ep\/\d+$/),
    group_topic: () => location.pathname.match(/^\/group\/topic\/\d+$/)
}
const isShowForm = () => localStorage['beuj_show_form_in_' + (util_page.ep() ? 'ep' : 'other')]
const setShowForm = (show) => localStorage['beuj_show_form_in_' + (util_page.ep() ? 'ep' : 'other')] = show ? TRUE : FALSE
const getShowFormActionText = () => isShowForm() ? '隐藏' : '显示'
class ClassHelper {
    constructor(ele) {
        this.ele = ele;
    }
    hasClass(name) {
        return this.ele.className.includes(name);
    }
    removeClass(name) {
        let list = this.ele.className.split(/ +/);
        let index = list.indexOf(name);
        if (index != -1) {
            list.splice(index, 1);
            this.ele.className = list.join(' ');
        }
        return this;
    }
    addClass(name) {
        this.ele.className = `${this.ele.className} ${name}`;
        return this;
    }
    toggleClass(name) {
        this.hasClass(name) ? this.removeClass(name) : this.addClass(name);
        return this;
    }
}
const array_last = (arr) => arr[arr.length - 1]
const safe_prop = (obj, prop, defaultValue) => obj ? obj[prop] : defaultValue
const score_to_index = (score) => 5 - (score + 3)
const index_to_score = (index) => 5 - index - 3
const score_to_str = (score) => `${score >= 0 ? '+' : ''}${score}`

function readVoteData() {
    const voteData = {
        voters: {},
        myScore: undefined,
        myReplyId: undefined,
        myUserId: getUserId(),
        hasSuffix: false,
        clearMyScore: function () {
            this.myScore = undefined
            this.myReplyId = undefined
            delete this.voters[this.myUserId]
        },
        parseReply: function ($reply) {
            let $message = this.getMessageInReply($reply)
            let score
            if ((score = this.getScoreInMessage($message)) !== undefined) {
                let userId = this.getUserIdInReply($reply)
                this.voters[userId] = score
                if (this.myUserId === userId) {
                    this.myScore = score
                    this.myReplyId = $reply.id
                }
                if (!this.hasSuffix && (
                    $message.innerHTML.includes(HOME_URL_PATH) // 老的推广链接, 删了.
                    || $message.innerHTML.includes(INSTALL_URL) // 新的推广链接
                )) {
                    this.hasSuffix = true
                }
                return true // 找到了新的评分时, 返回true
            }
            return false
        },
        getUserIdInReply: ($reply) => array_last($reply.querySelector(':scope > a.avatar').href.split('/')),
        getMessageInReply: ($reply) => $reply.querySelector('.message'),
        getScoreInMessage: function ($message) {
            if (this._group = $message.innerText.match(SCORE_REGEX)) {
                let score = Math.min(Math.max(-2, +this._group[1]), 2)
                return score
            }
            return undefined
        },
        getScoreInReply: function ($reply) { return this.getScoreInMessage(this.getMessageInReply($reply)) }
    }
    const replys = document.querySelectorAll('.row_reply')
    for (let $reply of replys) {
        voteData.parseReply($reply)
    }
    console.log('投票数据:', voteData)
    return voteData
}

const vote_to_bgm = (score, comment, hasSuffix) => new Promise((resolve, reject) => {
    // 发送一条推广评论
    comment = (comment || '').trim()

    let text = ''
    let scoreText = score_to_str(score)
    text += localStorage.beuj_need_mask ? `[color=white]${scoreText} [/color]` : `${scoreText} `
    comment && (text += comment)
    if (localStorage.beuj_need_suffix && !(beuj_only_one_suffix && hasSuffix)) {
        (text += `\n[align=right][url=${INSTALL_URL}]--来自不怕屏蔽的${script.name}[/url][/align]`)
    }

    document.querySelector('textarea#content').value = text
    document.querySelector('#new_comment #ReplyForm [type=submit]').click()
    resolve('ok')
})

function main() {
    let $comment_list
    if (!($comment_list = document.getElementById('comment_list'))) {
        console.log('不存在#comment_list, 不支持投票...')
        return
    }
    // 番剧讨论页: https://bgm.tv/ep/767931
    let $container = document.querySelector('#columnEpA .epDesc')
    // 小组讨论页: https://bgm.tv/group/topic/345237
    // 条目讨论版: https://bgm.tv/subject/topic/3022
    if (!$container) $container = document.querySelector('div.topic_content')
    // 人物页: https://bgm.tv/character/77
    if (!$container) $container = document.querySelector('#columnCrtB > div.detail')
    // 日志页面: https://bgm.tv/blog/46986
    if (!$container) $container = document.querySelector('#entry_content.blog_entry')
    // 依然没有, 则创建
    if (!$container) {
        $container = _('div', { className: 'borderNeue', style: { marginTop: '10px' } })
        $comment_list.parentElement.insertBefore($container, $comment_list)
    }
    const $poll_container = _('div', { id: 'poll_container', style: {/* width: '670px'*/ } })
    $container.appendChild($poll_container)
    let voteData = readVoteData()
    new MutationObserver((mutations) => {
        let toRefreshShow = false;
        // console.log(mutations)
        for (let mutation of mutations) {
            if (mutation.type === 'childList') {
                for (let node of mutation.addedNodes) {
                    // 当前在评论区删除回复时, 只是display: none, 并不会触发DOM树改变
                    // 故这里只处理增加了一条回复的情况
                    if (node.className.split(' ').includes('row_reply')) {
                        toRefreshShow |= voteData.parseReply(node)

                        // 给新增的评论添加 删除/编辑 按钮
                        let delPath, editPath;
                        const replyIdValue = array_last(node.id.split('_'))
                        if (util_page.ep()) { // 适配ep页面
                            delPath = `/erase/reply/ep/${replyIdValue}?gh=${getGh()}`
                            editPath = `/subject/ep/edit_reply/${replyIdValue}`
                        } else if (util_page.group_topic()) { // 适配小组讨论页面
                            delPath = `/erase/group/reply/${replyIdValue}?gh=${getGh()}`
                            editPath = `/group/reply/${replyIdValue}/edit`
                        }
                        if (delPath && editPath) {
                            const onDelClick = (e) => {
                                util_ui_alert('确定删除这条回复?', () => {
                                    ajax({ method: 'GET', dataType: 'json', url: `//${location.hostname}${delPath}&ajax=1` })
                                        .then(r => r.status === 'ok' ? r : Promise.reject(r))
                                        .then(r => {
                                            node.parentElement.removeChild(node)
                                            return r
                                        })
                                        .catch(e => {
                                            alert('删除失败\n' + util_stringify(e))
                                        })
                                }, 0)
                            }
                            let $replyInfo = node.querySelector(':scope > .re_info > small')
                            $replyInfo.appendChild(_('text', ' '))
                            $replyInfo.appendChild(_('a', { href: 'javascript:;', event: { click: onDelClick } }, [_('text', 'del')]))
                            $replyInfo.appendChild(_('text', ' / '))
                            $replyInfo.appendChild(_('a', { href: editPath }, [_('text', 'edit')]))
                        }
                    }
                }
                for (let node of mutation.removedNodes) {
                    // 处理移除reply的情况, 由脚本执行的删除, 会触发移除reply
                    if (node.className.split(' ').includes('row_reply')) {
                        // 移除reply时要做的处理其实比较复杂, 这里做简单化处理, 够用:
                        // 当移除的是自己的包含评分的reply时, 清除自己的评分
                        if (voteData.getScoreInReply(node) !== undefined
                            && voteData.getUserIdInReply(node) === voteData.myUserId) {
                            voteData.clearMyScore()
                            toRefreshShow = true
                        }
                    }
                }
            }
        }
        if (toRefreshShow) {
            show()
        }
    }).observe($comment_list, {
        childList: true,
        attributes: false,
    })
    show()
    function show() {
        if (is_login && voteData.myScore === undefined) {
            let title = document.title, $tmp
            // 番剧讨论页: https://bgm.tv/ep/767931
            // 人物页: https://bgm.tv/character/77
            if ($tmp = document.querySelector('#headerSubject .nameSingle a')) {
                title = $tmp.innerText
                if ($tmp = document.querySelector('div#columnEpA h2.title')) { // 番剧讨论页的ep
                    title += ' ' + $tmp.innerText.split(' ')[0]
                }
            }
            const $voteForm = showVote(title, () => {
                const val = $voteForm.elements.pollOption.value
                if (!val) {
                    alert("请选择后再投票!");
                    return;
                }
                let score = +val
                vote_to_bgm(score, $voteForm.elements.comment.value, voteData.hasSuffix)
                    .then((r) => {
                        // 发出评论后, 会触发DOM树改变, 前面的代码监听了DOM树改变, 在必要的时刻会更新投票区域, 故这里不需要手动更新
                        // voteData.voters[voteData.myUserId] = score
                        // voteData.myScore = score
                        // voteData.myReplyId = safe_prop(array_last(document.querySelectorAll('#comment_list > .row_reply')), 'id', 'no_id') // 评论列表的最后一条
                        // showVoteResult(voteData)
                        // 在ep页面, 有一个"标记为看过功能"
                        if (util_page.ep() && localStorage.beuj_flag_to_watched) {
                            let epId = array_last(location.pathname.split('/'))
                            return ajax({ method: 'POST', dataType: 'json', url: `//${location.hostname}/subject/ep/${epId}/status/watched?gh=${getGh()}&ajax=1`, })
                                .then(r => r.status === 'ok' ? r : Promise.reject(r))
                                .catch(e => {
                                    alert(`标记为看过 失败:\n${util_stringify(e)}`)
                                    return Promise.reject(e) // 继续抛出异常
                                })
                        } else {
                            return 'ok'
                        }
                    })
                    .then(r => console.log('result:', r))
                    .catch(e => console.error('error:', e))
            })
        } else {
            showVoteResult(voteData)
        }
    }

    function showVoteResult(voteData) {
        $poll_container.innerHTML = createVoteResultHtml(voteData.voters, voteData.myScore, voteData.myReplyId)
    }

    function showVote(title, onSubmit) {
        $poll_container.innerHTML = createVoteHtml(title)
        let $formContainer = $poll_container.querySelector('#form-container')
        let $actionShowForm = $poll_container.querySelector('#action-show-form')
        let $voteForm = $poll_container.querySelector('#vote-form')
        $actionShowForm.addEventListener('click', (e) => {
            setShowForm(!isShowForm())
            $actionShowForm.innerText = getShowFormActionText()
            new ClassHelper($formContainer).toggleClass('beuj-hidden')
        })
        $voteForm.onsubmit = function () {
            let comment_template
            let toSubmit = true
            if (comment_template = $voteForm.elements.comment_template.value) {
                toSubmit = false
                let templates = comment_template.split(' ')
                if (templates.length === commentTemplates.length) {
                    // 更新模板
                    commentTemplates = templates
                    localStorage.beuj_comment_templates = commentTemplates.join(' ')
                    toSubmit = true
                } else {
                    alert(`短评模板(${comment_template})不符合格式!\n需要用空格分隔, 例如:\n"${commentTemplates.join(' ')}"`)
                }
            }
            toSubmit && onSubmit()
            return false // no submit
        }
        $voteForm.elements.comment.addEventListener('keydown', (e) => {
            if (e.ctrlKey && (e.keyCode === 13 || e.keyCode === 10)) { // ctrl + enter
                $voteForm.elements.voteButton.click() // 直接form.submit()貌似有问题, 只能模拟提交
            }
        })
        $voteForm.addEventListener('change', (e) => {
            let name = e.target.name;
            let value = e.target.type === 'checkbox' ? (e.target.checked ? TRUE : FALSE) : e.target.value
            if (name.startsWith('beuj_')) {
                localStorage[name] = value
                console.log(name, ' => ', value);
            } else if (name === 'pollOption') {
                let score = +value
                let comment = $voteForm.elements.comment.value
                // 若简单评论为空, 或是评论模板中的值, 则修改简单评论为评论模板中的一个
                if (comment === '' || commentTemplates.includes(comment)) {
                    $voteForm.elements.comment.value = commentTemplates[score_to_index(score)]
                    $voteForm.elements.comment.select() // 全选简评区
                }
            } else if (name === 'modify_comment_template') {
                $voteForm.elements.comment_template.type = value ? 'text' : 'hidden'
            }
        })
        $voteForm.elements.beuj_need_mask.checked = localStorage.beuj_need_mask
        $voteForm.elements.beuj_need_suffix.checked = localStorage.beuj_need_suffix
        if ($voteForm.elements.beuj_flag_to_watched) {
            $voteForm.elements.beuj_flag_to_watched.checked = localStorage.beuj_flag_to_watched
        }
        return $voteForm
    }
}

function createVoteHtml(title) {
    let rows = ''
    for (let i = 0; i < commentTemplates.length; i++) {
        let scoreStr = score_to_str(index_to_score(i))
        rows += `<div style="margin: 3px 0;"><label><input type="radio" name="pollOption" value="${scoreStr}"> ${scoreStr} ${commentTemplates[i]}</label></div>`
    }
    return `
<div class="forum_category">${title} 投票
    <a id="action-show-form" class="beuj-float-right">${getShowFormActionText()}</a>
</div>
<div id="form-container" class="forum_boardrow1 ${isShowForm() ? '' : 'beuj-hidden'}" style="border-width: 0 1px 1px 1px;">
<form id="vote-form">
    ${rows}
    <textarea name="comment" id="vote-comment" class="reply" rows="1" placeholder="简短评价(Ctrl+Enter 快速提交)"></textarea>
    <input type="hidden" name="comment_template" class="inputtext" style="margin-bottom: 6px;" placeholder="短评模板; +2 +1 +0 -1 -2 分别对应的短评;使用空格分隔;">
    <br/>
    <input type="submit" name="voteButton" value="投票" class="inputButton" id="voteButton">
    <label class="form-option" title="没错, 短评模板时可以修改的"><input type="checkbox" name="modify_comment_template" > 修改短评模板 </input></label>
    ${util_page.ep() ? '<label class="form-option" title="同时将当前ep标记为看过"><input type="checkbox" name="beuj_flag_to_watched" > 标记为看过 </input></label>' : ''}
    <label class="form-option" title="会在评分的结尾追加'来自xxx脚本'的小尾巴, 为了防止刷屏, 只有当前页没有出现过小尾巴时才会追加." ><input type="checkbox" name="beuj_need_suffix" > 推荐脚本 </input></label>
    <label class="form-option" title="评分的数字显示成白色"><input type="checkbox" name="beuj_need_mask" > 隐藏评分 </input></label>
</form>
</div>
    `
}

function createVoteResultHtml(voters, myScore, myReplyId) {
    const counts = new Array(commentTemplates.length).fill(0) // 投+2->-2分的人数的数组
    const voterUserIds = Object.keys(voters)
    for (let userId of voterUserIds) {
        let score = voters[userId]
        counts[score_to_index(score)]++
    }

    let voterCount = voterUserIds.length
    let html = '';
    const myIndex = score_to_index(myScore)
    for (let i = 0; i < commentTemplates.length; i++) {
        let width = (counts[i] / voterCount * 100).toFixed(1)
        let isMyVote = myIndex === i
        html += `
            <tr>
                <td align="left">${score_to_str(index_to_score(i))} ${commentTemplates[i]}${isMyVote ? `<a href="#${myReplyId}" class="l">(your vote)</a>` : ''}</td>
                <td width="35%"><div class="vote_container" style="width: ${width}%">&nbsp;</div></td>
                <td width="25" align="center">${counts[i]}</td>
                <td width="40" align="right">${width}%</td>
            </tr>`
    }
    return `
<div class="forum_category">投票结果</div><div class="forum_boardrow1" style="border-width: 0 1px 1px 1px;">
    <table border="0" width="100%" cellpadding="" cellspacing="5">
        ${html}
    </table>
    <div style="text-align: center;">Voters: ${voterCount}</div>
</div>`
}

main()