NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Bangumi Forum Enhance Alpha
// @version 0.0.250906
// @description I know your (black) history!
// @updateURL https://openuserjs.org/meta/gyakkun/Bangumi_Forum_Enhance_Alpha.meta.js
// @downloadURL https://openuserjs.org/install/gyakkun/Bangumi_Forum_Enhance_Alpha.user.js
// @copyright gyakkun
// @include /^https?:\/\/(((fast\.)?bgm\.tv)|chii\.in|bangumi\.tv)\/(group|subject)\/topic\/*/
// @include /^https?:\/\/(((fast\.)?bgm\.tv)|chii\.in|bangumi\.tv)\/(ep|person|character|blog)\/*/
// @license MIT
// ==/UserScript==
(function () {
const INDEXED_DB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB
const SPACE_TYPE = document.location.pathname.split("/")[1]
const BA_FEH_API_URL = "https://bgm.nyamori.moe/forum-enhance/query"
const BA_FEH_CACHE_PREFIX = "ba_feh_" + SPACE_TYPE + "_" // + username
const FACE_KEY_GIF_MAPPING = {
"0": "44",
"140": "101",
"80": "41",
"54": "15",
"85": "46",
"104": "65",
"88": "49",
"62": "23",
"79": "40",
"53": "14",
"122": "83",
"92": "53",
"118": "79",
"141": "102",
"90": "51",
"76": "37",
"60": "21",
"128": "89",
"47": "08",
"68": "29",
"137": "98",
"132": "93"
}
const SPACE_ACTION_BUTTON_WORDING = {
"group": "小组讨论统计",
"subject": "条目讨论统计",
"ep": "章节讨论统计",
"character": "角色讨论统计",
"person": "人物讨论统计",
"blog": "日志发言统计"
};
const SPACE_TOPIC_URL = {
"group": "group/topic",
"subject": "subject/topic",
"ep": "ep",
"character": "character",
"person": "person",
"blog": "blog"
};
const SHOULD_DRAW_TOPIC_STAT = SPACE_TYPE === 'blog' || SPACE_TOPIC_URL[SPACE_TYPE].endsWith("topic")
const SHOULD_DRAW_LIKES_STAT = SPACE_TYPE.length % 3 != 0
attachActionButton()
registerOnClickEvent()
addStyleForTopPost()
purgeCache()
function getPostDivList() {
return $("div[id^='post_'")
}
function getUsernameAndPidOfPostDiv(postDiv) {
return {
username: postDiv.attr("data-item-user"),
postId: parseInt(postDiv.attr("id").substring("post_".length))
}
}
function getAllUsernameSet() {
var set = {}
getPostDivList().each(function () { set[$(this).attr("data-item-user")] = null })
if (SPACE_TYPE === 'blog') set[getBlogAuthorUsername()] = null
return set
}
function drawWrapper(username, postId, userStatObj) {
return `
<div id="ba-feh-wrapper-${postId}-${username}" class="subject_tag_section" style="margin: 1em;">
<div>
<div id="ba-feh-post-stat-${postId}-${username}">
<span class="tip">帖子统计:</span>
${drawPostStatData(userStatObj.postStat)}
</div>
${SHOULD_DRAW_TOPIC_STAT ? `
<div id="ba-feh-topic-stat-${postId}-${username}">
<span class="tip">主题统计:</span>
${drawTopicStatData(userStatObj.topicStat)}
</div>
` : ""}
${SHOULD_DRAW_LIKES_STAT ? `
<div id="ba-feh-like-stat-${postId}-${username}">
<span class="tip">收到贴贴:</span>
${drawFaceGrid(userStatObj.likeStat)}
</div>
<div id="ba-feh-like-rev-stat-${postId}-${username}">
<span class="tip">送出贴贴:</span>
${drawFaceGrid(userStatObj.likeRevStat)}
</div>
` : ""}
<div id="ba-feh-space-stat-${postId}-${username}">
<span class="tip">空间统计:</span>
${drawSpaceStatSection(userStatObj.spaceStat)}
</div>
<div id="ba-feh-recent-activities-${postId}-${username}">
${SHOULD_DRAW_TOPIC_STAT ? `
<span class="tip">最近发表:</span>
${drawRecentTopicSection(userStatObj.recentActivities.topic)}
<br/>
` : ""}
<span class="tip">最近回复:</span>
${drawRecentPostSection(userStatObj.recentActivities.post)}
${SHOULD_DRAW_LIKES_STAT ? `
<br/>
<span class="tip">最近送出贴贴:</span>
${drawRecentLikeRevSection(userStatObj.recentActivities.likeRev)}
` : ""}
</div>
</div>
</div>
`
}
function drawActionButton(username, postId) {
return `
<div class="action">
<a href="javascript:void(0);" class="icon" title="${SPACE_ACTION_BUTTON_WORDING[SPACE_TYPE]}">
<span data-dropped="false" class="ico" id="ba-feh-action-btn-${postId}-${username}" style="text-indent: 0px">▼</span><span class="title">${SPACE_ACTION_BUTTON_WORDING[SPACE_TYPE]}</span>
</a>
</div>
`
}
function drawActionButtonForBlogMainPost(username, postId) {
// FIXME: Need to double click
return `
<span class="action">
<a href="javascript:void(0);" class="thickbox icon" title="${SPACE_ACTION_BUTTON_WORDING[SPACE_TYPE]}">
<span class="title" id="ba-feh-action-btn-${postId}-${username}">▼ 统计</span>
</a>
</span>
`
}
function drawRecentPostSection(recentPostObjList) {
if (recentPostObjList.length == 0) {
return `<span>N/A</span>`
}
let inner = ""
for (p of recentPostObjList) {
inner += drawRecentPost(p)
}
return `
<div class="subject_tag_section">
${inner}
</div>
`
}
function drawRecentTopicSection(recentTopicObjList) {
if (recentTopicObjList.length == 0) {
return `<span>N/A</span>`
}
let inner = ""
for (t of recentTopicObjList) {
inner += drawRecentTopic(t)
}
return `
<div class="subject_tag_section">
${inner}
</div>
`
}
function drawSpaceStatSection(spaceStatObjList) {
if (spaceStatObjList.length == 0) {
return `<span>N/A</span>`
}
let inner = ""
for (s of spaceStatObjList) {
inner += drawSpaceStatData(s)
}
return `
<div class="subject_tag_section">
${inner}
</div>
`
}
function drawRecentLikeRevSection(recentLikeRevObjList) {
if (recentLikeRevObjList.length == 0) {
return `<span>N/A</span>`
}
let inner = ""
for (t of recentLikeRevObjList) {
inner += drawRecentLikeRev(t)
}
return `
<div class="subject_tag_section">
${inner}
</div>
`
}
function drawRecentPost(postBriefObj) {
return `<a class="l inner" target="_blank"
rel="nofollow external noopener noreferrer"
href="/${SPACE_TOPIC_URL[SPACE_TYPE]}/${postBriefObj.mid}#post_${postBriefObj.pid}"
title="${postBriefObj.spaceDisplayName || ""}"
>
${postBriefObj.title} <small class="grey">${formatDateline(postBriefObj.dateline)}</small></a>`
}
function drawRecentTopic(topicBriefObj) {
return `<a class="l inner" target="_blank"
rel="nofollow external noopener noreferrer"
href="/${SPACE_TOPIC_URL[SPACE_TYPE]}/${topicBriefObj.id}"
title="${topicBriefObj.spaceDisplayName || ""}"
>
${topicBriefObj.title} <small class="grey">${formatDateline(topicBriefObj.dateline)}</small></a>`
}
function drawRecentLikeRev(likeRevBrief) {
let likeRevObjListHtml = ""
for (l of likeRevBrief.likeRevList) {
likeRevObjListHtml += `
<a target="_blank" rel="nofollow external noopener noreferrer"
href="/${SPACE_TOPIC_URL[SPACE_TYPE]}/${likeRevBrief.mid}#post_${l.pid}">
<img style="width: 18px;height: 18px;" src="/img/smiles/tv/${FACE_KEY_GIF_MAPPING[l.faceKey]}.gif"></img>
</a>
`
}
return `<p><a class="l inner" target="_blank" rel="nofollow external noopener noreferrer"
href="/${SPACE_TOPIC_URL[SPACE_TYPE]}/${likeRevBrief.mid}"
title="${likeRevBrief.spaceDisplayName || ""}"
>
${likeRevBrief.title}
<small class="grey">
${formatDateline(likeRevBrief.dateline)}
</small>
</a><small class="grey">:</small>${likeRevObjListHtml}</p>`
}
function drawSpaceStatData(spaceStatObj) {
let { name, displayName, topic, post, like, likeRev } = spaceStatObj
let isNameTooLong = displayName.length > 10
displayName = displayName.substring(0, Math.min(10, displayName.length))
if (isNameTooLong) displayName += "..."
let topicDrawing = drawTopicStatData(topic)
let postDrawing = drawPostStatData(post)
let likeRevDrawing = drawLikeStatData(likeRev)
let likeDrawing = drawLikeStatData(like)
let spacePath = ""
switch (SPACE_TYPE) {
case "blog": spacePath = "user"; break
case "ep": spacePath = "subject"; break
default: spacePath = SPACE_TYPE
}
return `
<div>
<a href="/${spacePath}/${name}" class="l" target="_blank" rel="nofollow external noopener noreferrer">${displayName}</a>
<span class="tip">帖子:</span>
${postDrawing}
${SHOULD_DRAW_TOPIC_STAT ? `
<span class="tip">主题:</span>
${topicDrawing}
` : ""}
${SHOULD_DRAW_LIKES_STAT ? `
<span class="tip">送出贴贴:</span>
${likeRevDrawing}
<span class="tip">收到贴贴:</span>
${likeDrawing}
` : ""}
</div>
`
}
function drawPostStatData(postStatObj) {
return `
<small class="grey">
${postStatObj.total}(T)
${postStatObj.r7d > 0 ? `/<span>${postStatObj.r7d}(7d)</span>` : ""}
${postStatObj.r30d > 0 ? `/<span>${postStatObj.r30d}(30d)</span>` : ""}
${postStatObj.deleted > 0 ? `/<span style="color: red;">${postStatObj.deleted}(D)</span>` : ""}
${postStatObj.adminDeleted > 0 ? `/<span style="color: yellowgreen;">${postStatObj.adminDeleted}(AD)</span>` : ""}
${postStatObj.violative > 0 ? `/<span style="color: rgb(50, 255, 245);">${postStatObj.violative}(V)</span>` : ""}
${postStatObj.collapsed > 0 ? `/<span style="color: rgb(89, 116, 252);">${postStatObj.collapsed}(F)</span>` : ""}
</small>
`
}
function drawTopicStatData(topicStatObj) {
return `
<small class="grey">
${topicStatObj.total}(T)
${topicStatObj.r7d > 0 ? `/<span>${topicStatObj.r7d}(7d)</span>` : ""}
${topicStatObj.r30d > 0 ? `/<span>${topicStatObj.r30d}(30d)</span>` : ""}
${topicStatObj.deleted > 0 ? `/<span style="color: red;">${topicStatObj.deleted}(D)</span>` : ""}
${topicStatObj.silent > 0 ? `/<span style="color: rgb(255, 145, 0);;">${topicStatObj.silent}(S)</span>` : ""}
${topicStatObj.closed > 0 ? `/<span style="color: rgb(164, 75, 253);">${topicStatObj.closed}(C)</span>` : ""}
${topicStatObj.reopen > 0 ? `/<span style="color: rgb(53, 188, 134);">${topicStatObj.reopen}(R)</span>` : ""}
</small>
`
}
function drawLikeStatData(likeStatForSpaceObj) {
return `
<small class="grey">
${likeStatForSpaceObj.total}(T)
</small>
`
}
function drawFaceGrid(faceMap) {
let extracted = extractSortedListOfFace(faceMap)
if (extracted.length == 0) {
return `<span>N/A</span>`
}
let inner = ""
for (p of extracted) {
let faceKey = p[0]
let faceCount = p[1]
let facePicValue = FACE_KEY_GIF_MAPPING[faceKey]
inner += `
<a class="item" data-like-value="${faceKey}">
<span class="emoji" style="background-image: url('/img/smiles/tv/${facePicValue}.gif');"></span>
<span class="num">${faceCount}</span>
</a>
`
}
return `
<div class="likes_grid" style="float: none;">
${inner}
</div>
`
}
function extractSortedListOfFace(faceMap) {
let res = [] // 2d arr
for (key in faceMap) {
res.push([key, faceMap[key]])
}
res = res.sort((a, b) => b[1] - a[1])
return res
}
function attachActionButton() {
console.debug(`[BA_FEH] attaching action button`)
getPostDivList().each(function () {
let { username, postId } = getUsernameAndPidOfPostDiv($(this))
$(this).find("div.post_actions.re_info > div:nth-child(1)").first().after(
drawActionButton(username, postId)
)
})
if (SPACE_TYPE === 'blog') {
let username = getBlogAuthorUsername()
let postId = "n" + window.location.pathname.split("/")[2]
let addToMyCollectionActionSpanSelector = "#columnA > div.entry-actions > div.post_actions > span.action:nth-child(1)"
$(addToMyCollectionActionSpanSelector).before(drawActionButtonForBlogMainPost(username, postId))
}
}
function getBlogAuthorUsername() {
let authorAvatarSelecter = "#viewEntry > div.author.user-card > a"
let username = $(authorAvatarSelecter).attr("href").split("/")[2]
return username
}
function registerOnClickEvent() {
$("span[id^='ba-feh-action-btn-'").each(function () {
let that = $(this)
let pid = that.attr("id").split("-")[4]
let username = that.attr("id").split("-")[5]
let isBlogMainPost = SPACE_TYPE === 'blog' && pid.startsWith("n")
that.click(async () => {
if (that.attr("data-dropped") === "false") {
if (isBlogMainPost) {
that.html("* 加载")
} else {
that.html("*")
}
if ($(`#ba-feh-wrapper-${pid}-${username}`).length > 0) {
$(`#ba-feh-wrapper-${pid}-${username}`).show()
} else {
let userStatObj = await getUserStatObj(username)
let baFehWrapper = drawWrapper(username, pid, userStatObj)
if ($("#likes_grid_" + pid).length > 0) {
$("#likes_grid_" + pid).after(baFehWrapper)
} else if ($(`#post_${pid} > div.inner > div > div.message`).length > 0) {
$(`#post_${pid} > div.inner > div > div.message`).append(baFehWrapper)
} else if ($(`#post_${pid} > div.inner > div.cmt_sub_content`).length > 0) {
$(`#post_${pid} > div.inner > div.cmt_sub_content`).after(baFehWrapper)
} else if (isBlogMainPost) {
$("#entry_content").after(baFehWrapper)
} else {
console.error(`[BA_FEH] No element to mount ba_feh wrapper for postId-${pid}!`)
}
}
if (isBlogMainPost) {
that.html("▲ 统计")
} else {
that.html("▲")
}
that.attr("data-dropped", "true")
} else {
if (isBlogMainPost) {
that.html("▼ 统计")
} else {
that.html("▼")
}
that.attr("data-dropped", "false")
$(`#ba-feh-wrapper-${pid}-${username}`).hide()
}
})
})
}
async function getUserStatObj(username) {
if (await areYouCached(username)) {
return (await getCacheByUsername(username))
}
let allUsernameSet = getAllUsernameSet()
for (un in allUsernameSet) {
if (await areYouCached(un))
delete allUsernameSet[un]
}
let usernameListToFetch = Object.keys(allUsernameSet)
console.debug(`[BA_FEH] Fetching: ${JSON.stringify(usernameListToFetch)}`)
let fetched = await fetch(BA_FEH_API_URL, {
body: JSON.stringify({ users: usernameListToFetch, type: SPACE_TYPE }),
method: "POST"
}).then(d => d.json())
.catch(e => console.error("[BA_FEH] Exception when fetching data: " + e, e))
for (u in fetched) {
await storeInCache(u, fetched[u])
}
return await getCacheByUsername(username)
}
async function storeInCache(username, userStatObj) {
let ck = `${BA_FEH_CACHE_PREFIX}${username}`
if (!!INDEXED_DB) {
await getIndexedDBManager().setItem(ck, userStatObj)
} else {
sessionStorage[ck] = JSON.stringify(userStatObj)
}
}
async function areYouCached(username) {
let ck = `${BA_FEH_CACHE_PREFIX}${username}`
if (!!INDEXED_DB) {
let statObj = (await getIndexedDBManager().getItem(ck))
if (!!!statObj) return false
return !isUserStatCacheExpired(statObj)
} else if (!!sessionStorage[ck]) {
let statObj = JSON.parse(sessionStorage[ck])
return !isUserStatCacheExpired(statObj)
}
return false
}
function isUserStatCacheExpired(userStatObj) {
if ((new Date().valueOf()) > (userStatObj?._meta?.expiredAt ?? (new Date().valueOf()))) {
return true
}
return false
}
async function getCacheByUsername(username) {
let ck = `${BA_FEH_CACHE_PREFIX}${username}`
if (!!INDEXED_DB) {
return (await getIndexedDBManager().getItem(ck))
}
return JSON.parse(sessionStorage[ck])
}
function formatDateline(dateline /* epoch seconds */) {
let msWithOffset = 1000 * (dateline - new Date().getTimezoneOffset(/* minutes */) * 60)
let d = new Date(msWithOffset)
let [year, month, day] = d.toISOString().split("T")[0].split("-")
return `${year.substring(2)}${month}${day}`
}
// Thank you https://juejin.cn/post/7228480373306818619
function getIndexedDBManager() {
const DATA_BASE_NAME = 'BA_FEH'
const TABLE_NAME = 'CACHE'
const UNIQ_KEY = 'BA_FEH_CACHE_KEY'
let dataBase = null
function getDataBase() {
if (dataBase) {
return dataBase
}
return new Promise(resolve => {
const request = indexedDB.open(DATA_BASE_NAME)
request.onupgradeneeded = e => {
const db = e.target.result
if (!db.objectStoreNames.contains(TABLE_NAME)) {
db.createObjectStore(TABLE_NAME, { keyPath: UNIQ_KEY })
}
}
request.onsuccess = e => {
const db = e.target.result
dataBase = db
resolve(db)
}
})
}
return {
async setItem(key, value) {
const dataBase = await getDataBase()
return new Promise(resolve => {
const request = dataBase.transaction(TABLE_NAME, 'readwrite')
.objectStore(TABLE_NAME)
.put({ data: value, [UNIQ_KEY]: key })
request.onsuccess = resolve('success')
})
},
async getItem(key) {
const dataBase = await getDataBase()
return new Promise(resolve => {
const request = dataBase.transaction(TABLE_NAME)
.objectStore(TABLE_NAME)
.get(key)
request.onsuccess = () => {
resolve(request.result?.data)
}
})
},
async deleteItem(key) {
const dataBase = await getDataBase()
return new Promise(resolve => {
const request = dataBase.transaction(TABLE_NAME, 'readwrite')
.objectStore(TABLE_NAME)
.delete(key)
request.onsuccess = () => {
resolve(request.result === undefined)
}
})
},
async keys() {
const keys = {} // use as set
const dataBase = await getDataBase()
return new Promise(resolve => {
const request = dataBase.transaction(TABLE_NAME)
.objectStore(TABLE_NAME)
.openCursor()
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.continue()
keys[cursor.value[UNIQ_KEY]] = true
} else {
resolve(keys)
}
}
})
}
}
}
async function purgeCache() {
if (!INDEXED_DB) return
let timing = new Date().valueOf()
let dbMgr = getIndexedDBManager()
let keys = await dbMgr.keys()
let ctr = 0
let deleted = []
console.debug("[BA_FEH] Keys before purging cache: " + JSON.stringify(Object.keys(keys)))
for (k in keys) {
let statObj = await dbMgr.getItem(k)
if (!statObj) continue
if (isUserStatCacheExpired(statObj)) {
await dbMgr.deleteItem(k)
ctr++
deleted.push(k)
}
}
timing = (new Date().valueOf()) - timing
console.debug(`[BA_FEH] The following expired cache keys has been removed in db: ${JSON.stringify(deleted)}`)
console.log(`[BA_FEH] Timing for purging cache: ${timing}ms. ${ctr} rows deleted`)
}
function addStyleForTopPost() {
let topPostStyle = document.createElement('style');
topPostStyle.innerHTML = `
.postTopic div[id^='ba-feh-wrapper-'] {
float: right;
}
`
document.head.appendChild(topPostStyle);
}
})();