Bangumi Evaluation

Bangumi Evaluation Script

// ==UserScript==
// @name         Bangumi Evaluation
// @name:zh-CN   Bangumi评分脚本・改
// @namespace    https://github.com/ipcjs/
// @version      2.0.1
// @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 {
        border-width: 0 1px 1px 1px;
        border-color: #ebebeb;
        border-style: solid;
        padding: 6px 4px;
        vertical-align: top
    }
    html[data-theme='dark'] .forum_boardrow1 {
        border-color: rgba(255,255,255,0.1);
    }
    html[data-theme='dark'] .forum_category {
        background-color: #37393b;
    }
    .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 = 'https://greasyfork.org/zh-CN/scripts/39144'
const SCORE_REGEX = /^\s*([+-]\d+)(\W[^]*)?$/ // 以数字开头的评论
const SCORE_REGEX_ZERO = /^([\u200c\u200d]{3})([^\u200c\u200d].*)?$/
localStorage.beuj_add_suffix === undefined && (localStorage.beuj_add_suffix = FALSE)
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}`
const score_to_commit_str = (score) => {
    // 用零宽字符转换成二进制, 表示评分
    const binaryStr = `000${(score + 3).toString(2)}`.substr(-3)
    let str = ''
    for (let c of binaryStr) {
        str += c === '0' ? '\u200c' : '\u200d'
    }
    return str
}

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) {
            const text = $message.innerText
            // console.log(text)
            if (this._group = text.match(SCORE_REGEX)) {
                let score = Math.min(Math.max(-2, +this._group[1]), 2)
                return score
            } else if (this._group = text.match(SCORE_REGEX_ZERO)) {
                let binaryStr = ''
                for (let c of this._group[1]) {
                    binaryStr += c === '\u200c' ? '0' : '1'
                }
                return Number.parseInt(binaryStr, 2) - 3
            }
            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_commit_str(score)
    text += scoreText
    comment && (text += comment)
    if (localStorage.beuj_add_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_add_suffix.checked = localStorage.beuj_add_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'}">
<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_add_suffix" > 推荐脚本 </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">
    <table border="0" width="100%" cellpadding="" cellspacing="5">
        ${html}
    </table>
    <div style="text-align: center;">Voters: ${voterCount}</div>
</div>`
}

main()