// ==UserScript== // @name Bangumi 年鉴 // @namespace syaro.io // @version 1.3.17 // @author 神戸小鳥 @vickscarlet // @description 根据Bangumi的时光机数据生成年鉴 // @license MIT // @icon https://bgm.tv/img/favicon.ico // @homepage https://github.com/bangumi/scripts/blob/master/vickscarlet/scripts/report // @match *://bgm.tv/user/* // @match *://chii.in/user/* // @match *://bangumi.tv/user/* // @downloadURL https://update.greasyfork.icu/scripts/456969/Bangumi%20%E5%B9%B4%E9%89%B4.user.js // @updateURL https://update.greasyfork.icu/scripts/456969/Bangumi%20%E5%B9%B4%E9%89%B4.meta.js // ==/UserScript== (function () { 'use strict'; function callWhenDone(fn) { let done = true; return async () => { if (!done) return; done = false; await fn(); done = true; }; } function callNow(fn) { fn(); return fn; } const svgTags = [ "svg", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text", "g", "defs", "use", "symbol", "image", "clipPath", "mask", "pattern" ]; function setEvents(element, events) { for (const [event, listener] of Object.entries(events)) { element.addEventListener(event, listener); } return element; } function setProps(element, props) { if (!props || typeof props !== "object") return element; for (const [key, value] of Object.entries(props)) { if (value == null) continue; if (key === "events") { setEvents(element, value); } else if (key === "class") { addClass(element, value); } else if (key === "style" && typeof value === "object") { setStyle(element, value); } else if (key.startsWith("data-")) { element.setAttribute(key, String(value)); } else { element[key] = value; } } return element; } function addClass(element, value) { element.classList.add(...[value].flat()); return element; } function setStyle(element, styles) { for (let [k, v] of Object.entries(styles)) { if (typeof v === "number" && v !== 0 && !["zIndex", "fontWeight"].includes(k)) { v = v + "px"; } element.style[k] = v; } return element; } function create(name, props, ...childrens) { if (name == null) return null; const isSVG = name === "svg" || typeof name === "string" && svgTags.includes(name); if (isSVG) return createSVG(name, props, ...childrens); const element = name instanceof Element ? name : document.createElement(name); if (props === void 0) return element; if (Array.isArray(props) || props instanceof Node || typeof props !== "object") { return append(element, props, ...childrens); } return append(setProps(element, props), ...childrens); } function append(element, ...childrens) { const tag = element.tagName.toLowerCase(); if (svgTags.includes(tag)) { return appendSVG(element, ...childrens); } for (const child of childrens) { if (Array.isArray(child)) { element.append(create(...child)); } else if (child instanceof Node) { element.appendChild(child); } else { element.append(document.createTextNode(String(child))); } } return element; } function createSVG(name, props, ...childrens) { const element = document.createElementNS("http://www.w3.org/2000/svg", name); if (name === "svg") element.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); if (props === void 0) return element; if (Array.isArray(props) || props instanceof Node || typeof props !== "object") { return appendSVG(element, props, ...childrens); } return appendSVG(setProps(element, props), ...childrens); } function appendSVG(element, ...childrens) { for (const child of childrens) { if (Array.isArray(child)) { element.append(createSVG(...child)); } else if (child instanceof Node) { element.appendChild(child); } else { element.append(document.createTextNode(String(child))); } } return element; } function addStyle(...styles) { const style = document.createElement("style"); style.append(document.createTextNode(styles.join("\n"))); document.head.appendChild(style); return style; } function removeAllChildren(element) { while (element.firstChild) element.removeChild(element.firstChild); return element; } const loadScript = /* @__PURE__ */ (() => { const loaded = /* @__PURE__ */ new Set(); const pedding = /* @__PURE__ */ new Map(); return async (src) => { if (loaded.has(src)) return; const list = pedding.get(src) ?? []; const p = new Promise((resolve) => list.push(resolve)); if (!pedding.has(src)) { pedding.set(src, list); const script = document.createElement("script"); script.src = src; script.type = "text/javascript"; script.onload = () => { loaded.add(src); list.forEach((resolve) => resolve()); }; document.body.appendChild(script); } return p; }; })(); class Event { static #listeners = /* @__PURE__ */ new Map(); static on(event, listener) { if (!this.#listeners.has(event)) this.#listeners.set(event, /* @__PURE__ */ new Set()); this.#listeners.get(event).add(listener); } static emit(event, ...args) { if (!this.#listeners.has(event)) return; for (const listener of this.#listeners.get(event).values()) listener(...args); } static off(event, listener) { if (!this.#listeners.has(event)) return; this.#listeners.get(event).delete(listener); } } class Cache { constructor({ hot, last }) { this.#hotLimit = hot ?? 0; this.#lastLimit = last ?? 0; this.#cacheLimit = this.#hotLimit + this.#lastLimit; } #hotLimit; #lastLimit; #cacheLimit; #hotList = []; #hot = /* @__PURE__ */ new Set(); #last = /* @__PURE__ */ new Set(); #pedding = /* @__PURE__ */ new Set(); #cache = /* @__PURE__ */ new Map(); #times = /* @__PURE__ */ new Map(); #cHot(key) { if (!this.#hotLimit) return false; const counter = this.#times.get(key) || { key, cnt: 0 }; counter.cnt++; this.#times.set(key, counter); if (this.#hot.size == 0) { this.#hotList.push(counter); this.#hot.add(key); this.#pedding.delete(key); return true; } const i = this.#hotList.indexOf(counter); if (i == 0) return true; if (i > 0) { const up = this.#hotList[i - 1]; if (counter.cnt > up.cnt) this.#hotList.sort((a, b) => b.cnt - a.cnt); return true; } if (this.#hot.size < this.#hotLimit) { this.#hotList.push(counter); this.#hot.add(key); this.#pedding.delete(key); return true; } const min = this.#hotList.at(-1); if (counter.cnt <= min.cnt) return false; this.#hotList.pop(); this.#hot.delete(min.key); if (!this.#last.has(min.key)) this.#pedding.add(min.key); this.#hotList.push(counter); this.#hot.add(key); this.#pedding.delete(key); return true; } #cLast(key) { if (!this.#lastLimit) return false; this.#last.delete(key); this.#last.add(key); this.#pedding.delete(key); if (this.#last.size <= this.#lastLimit) return true; const out = this.#last.values().next().value; this.#last.delete(out); if (!this.#hot.has(out)) this.#pedding.add(out); return true; } async get(key, query) { const data = this.#cache.get(key) ?? await query(); const inHot = this.#cHot(key); const inLast = this.#cLast(key); if (inHot || inLast) this.#cache.set(key, data); let i = this.#cache.size - this.#cacheLimit; if (!i) return data; for (const key2 of this.#pedding) { if (!i) return data; this.#cache.delete(key2); this.#pedding.delete(key2); i--; } return data; } update(key, value) { if (!this.#cache.has(key)) this.#cache.set(key, value); } clear() { this.#cache.clear(); } } class Collection { constructor(master, { collection, options, indexes, cache }) { this.#master = master; this.#collection = collection; this.#options = options; this.#indexes = indexes; if (options?.keyPath && cache && cache.enabled) { this.#cache = new Cache(cache); } } #master; #collection; #options; #indexes; #cache; get collection() { return this.#collection; } get options() { return this.#options; } get indexes() { return this.#indexes; } async transaction(handler, mode) { return this.#master.transaction( this.#collection, async (store) => { const request = await handler(store); return new Promise((resolve, reject) => { request.addEventListener("error", (e) => reject(e)); request.addEventListener("success", () => resolve(request.result)); }); }, mode ); } #index(store, index = "") { if (!index) return store; return store.index(index); } async get(key, index) { const handler = () => this.transaction((store) => this.#index(store, index).get(key)); if (this.#cache && this.#options?.keyPath && !index && typeof key == "string") { return this.#cache.get(key, handler); } return handler(); } async getAll(key, count, index) { return this.transaction((store) => this.#index(store, index).getAll(key, count)); } async getAllKeys(key, count, index) { return this.transaction((store) => this.#index(store, index).getAllKeys(key, count)); } async put(data) { if (this.#cache) { let key; if (Array.isArray(this.#options.keyPath)) { key = []; for (const path of this.#options.keyPath) { key.push(data[path]); } key = key.join("/"); } else { key = data[this.#options.keyPath]; } this.#cache.update(key, data); } return this.transaction((store) => store.put(data), "readwrite").then((_) => true); } async delete(key) { return this.transaction((store) => store.delete(key), "readwrite").then((_) => true); } async clear() { if (this.#cache) this.#cache.clear(); return this.transaction((store) => store.clear(), "readwrite").then((_) => true); } } class Database { constructor({ dbName, version, collections, blocked }) { this.#dbName = dbName; this.#version = version; this.#blocked = blocked || { alert: false }; for (const options of collections) { this.#collections.set(options.collection, new Collection(this, options)); } } #dbName; #version; #collections = /* @__PURE__ */ new Map(); #db = null; #blocked; async init() { this.#db = await new Promise((resolve, reject) => { const request = window.indexedDB.open(this.#dbName, this.#version); request.addEventListener( "error", () => reject({ type: "error", message: request.error }) ); request.addEventListener("blocked", () => { const message = this.#blocked?.message || "indexedDB is blocked"; if (this.#blocked?.alert) alert(message); reject({ type: "blocked", message }); }); request.addEventListener("success", () => resolve(request.result)); request.addEventListener("upgradeneeded", () => { for (const c of this.#collections.values()) { const { collection, options, indexes } = c; let store; if (!request.result.objectStoreNames.contains(collection)) store = request.result.createObjectStore(collection, options); else store = request.transaction.objectStore(collection); if (!indexes) continue; for (const { name, keyPath, unique } of indexes) { if (store.indexNames.contains(name)) continue; store.createIndex(name, keyPath, { unique }); } } }); }); return this; } async transaction(collection, handler, mode = "readonly") { if (!this.#db) await this.init(); return new Promise(async (resolve, reject) => { const transaction = this.#db.transaction(collection, mode); const store = transaction.objectStore(collection); const result = await handler(store); transaction.addEventListener("error", (e) => reject(e)); transaction.addEventListener("complete", () => resolve(result)); }); } async get(collection, key, index) { return this.#collections.get(collection).get(key, index); } async getAll(collection, key, count, index) { return this.#collections.get(collection).getAll(key, count, index); } async getAllKeys(collection, key, count, index) { return this.#collections.get(collection).getAllKeys(key, count, index); } async put(collection, data) { return this.#collections.get(collection).put(data); } async delete(collection, key) { return this.#collections.get(collection).delete(key); } async clear(collection) { return this.#collections.get(collection).clear(); } async clearAll() { for (const c of this.#collections.values()) await c.clear(); return true; } } const db = new Database({ dbName: "VReport", version: 6, collections: [ { collection: "pages", options: { keyPath: "url" }, indexes: [{ name: "url", keyPath: "url", unique: true }] }, { collection: "times", options: { keyPath: "id" }, indexes: [{ name: "id", keyPath: "id", unique: true }] } ] }); const uid = window.location.pathname.split("/")[2] || ""; const Types = { anime: { sort: 1, value: "anime", name: "动画", action: "看", unit: "部" }, game: { sort: 2, value: "game", name: "游戏", action: "玩", unit: "部" }, music: { sort: 3, value: "music", name: "音乐", action: "听", unit: "张" }, book: { sort: 4, value: "book", name: "图书", action: "读", unit: "本" }, real: { sort: 5, value: "real", name: "三次元", action: "看", unit: "部" } }; const SubTypes = { collect: { sort: 1, value: "collect", name: "$过", checked: true }, do: { sort: 2, value: "do", name: "在$", checked: false }, dropped: { sort: 3, value: "dropped", name: "抛弃", checked: false }, on_hold: { sort: 4, value: "on_hold", name: "搁置", checked: false }, wish: { sort: 5, value: "wish", name: "想$", checked: false } }; const AnimeTypeTimes = { WEB: 23 * 60 + 40, TV: 23 * 60 + 40, OVA: 45 * 60, OAD: 45 * 60, 剧场版: 90 * 60 }; function formatSubType(subType, type) { const action = Types[type].action; return SubTypes[subType].name.replace("$", action); } function pad02(n) { return n.toString().padStart(2, "0"); } function timeFormat(time, day = false) { const s = time % 60; const m = (time - s) / 60 % 60; if (!day) { const h2 = (time - s - m * 60) / 3600; return `${h2}:${pad02(m)}:${pad02(s)}`; } const h = (time - s - m * 60) / 3600 % 24; const d = (time - s - m * 60 - h * 3600) / 86400; if (d) return `${d}天${pad02(h)}:${pad02(m)}:${pad02(s)}`; return `${h}:${pad02(m)}:${pad02(s)}`; } function easeOut(curtime, begin, end, duration) { let x = curtime / duration; let y = -x * x + 2 * x; return begin + (end - begin) * y; } function countMap(length) { return new Map(new Array(length).fill(0).map((_, i) => [i, 0])); } function groupBy(list, group) { const groups = /* @__PURE__ */ new Map(); for (const item of list) { const key = item[group]; if (groups.has(key)) groups.get(key).push(item); else groups.set(key, [item]); } return groups; } function groupCount(list, group, groups = /* @__PURE__ */ new Map()) { for (const item of list) { const key = typeof group == "function" ? group(item) : item[group]; groups.set(key, (groups.get(key) || 0) + 1); } return groups; } async function element2Canvas(element) { await loadScript("https://html2canvas.hertzen.com/dist/html2canvas.min.js"); return html2canvas(element, { allowTaint: true, logging: false, backgroundColor: "#1c1c1c" }); } async function f(url) { Event.emit("process", { type: "fetch", data: { url } }); const html = await fetch(window.location.origin + "/" + url).then((res) => res.text()); if (html.includes("503 Service Temporarily Unavailable")) return null; const e = document.createElement("html"); e.innerHTML = html.replace(//g, ''); return e; } async function fl(type, subType, p = 1, expire = 30) { Event.emit("process", { type: "parse", data: { type, subType, p } }); const url = `${type}/list/${uid}/${subType}?page=${p}`; let data = await db.get("pages", url); if (data && data.time + expire * 6e4 > Date.now()) return data; const e = await f(url); if (!e) return null; const list = Array.from(e.querySelectorAll("#browserItemList > li")).map( (li) => { const id = li.querySelector("a").href.split("/").pop(); const t = li.querySelector("h3"); const title = t.querySelector("a").innerText; const jp_title = t.querySelector("small")?.innerText; const img = li.querySelector("span.img")?.getAttribute("src").replace("cover/c", "cover/l") || "//bgm.tv/img/no_icon_subject.png"; const time2 = new Date(li.querySelector("span.tip_j").innerText); const year = time2.getFullYear(); const month = time2.getMonth(); const star = parseInt( li.querySelector("span.starlight")?.className.match(/stars(\d{1,2})/)[1] ) || 0; const tags = li.querySelector("span.tip")?.textContent.trim().match(/标签:\s*(.*)/)?.[1].split(/\s+/) || []; return { id, subType, title, jp_title, img, time: time2, year, month, star, tags }; } ); const edge = e.querySelector("span.p_edge"); let max; if (edge) { max = Number(/\/\s*(\d+)\s*\)/.exec(edge.textContent)?.[1] || 1); } else { const ap = e.querySelectorAll("a.p"); if (ap.length == 0) { max = 1; } else { let cursor = ap[ap.length - 1]; if (cursor.innerText == "››") cursor = cursor.previousElementSibling; max = Number(cursor.textContent) || 1; } } const time = Date.now(); data = { url, list, max, time }; if (p == 1) { const tags = Array.from(e.querySelectorAll("#userTagList > li > a.l")).map( (l) => l.childNodes[1].textContent ); data.tags = tags; } await db.put("pages", data); return data; } async function ft(type) { Event.emit("process", { type: "tags", data: { type } }); const data = await fl(type, "collect"); return data?.tags; } function calcTime(s) { let m = /[时片]长:\s*(\d{2}):(\d{2}):(\d{2})/.exec(s); if (m) return parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]); m = /[时片]长:\s*(\d{2}):(\d{2})/.exec(s); if (m) return parseInt(m[1]) * 60 + parseInt(m[2]); m = /[时片]长:\s*(\d+)\s*[m分]/.exec(s); if (m) return parseInt(m[1]) * 60; return 0; } async function ftime(id) { let data = await db.get("times", id); if (data) { if (data.time) { const { time: time2 } = data; return { a: true, time: time2 }; } else { const { eps: eps2, type: type2 } = data; const time2 = eps2 * AnimeTypeTimes[type2] || 0; return { a: false, time: time2 }; } } const e = await f(`subject/${id}/ep`); const c = (l) => Array.from(l).reduce((a, e2) => a + calcTime(e2.innerText), 0); let time = c(e.querySelectorAll("ul.line_list > li > small.grey")); if (time) { data = { id, time }; await db.put("times", data); return { time, a: true }; } const se = await f(`subject/${id}`); time = c(se.querySelectorAll("ul#infobox > li")); if (time) { data = { id, time }; await db.put("times", data); return { time, a: true }; } const type = se.querySelector("h1.nameSingle > small")?.textContent; const eps = e.querySelectorAll("ul.line_list > li > h6").length; data = { id, type, eps }; await db.put("times", data); return { time: eps * AnimeTypeTimes[type] || 0, a: false }; } async function totalTime(list) { const total = { total: { name: "总计", time: 0, count: 0 }, normal: { name: "精确", time: 0, count: 0 }, guess: { name: "推测", time: 0, count: 0 }, unknown: { name: "未知", time: 0, count: 0 } }; Event.emit("process", { type: "totalTime", data: { total: list.length } }); for (const { id } of list) { Event.emit("process", { type: "totalTimeItem", data: { id, count: total.total.count + 1 } }); const { time, a } = await ftime(id); if (a) { total.normal.count++; total.normal.time += time; } else if (time) { total.guess.count++; total.guess.time += time; } else { total.unknown.count++; } total.total.count++; total.total.time += time; } return total; } async function bsycs(type, subtype, year) { const data = await fl(type, subtype); if (!data) return [1, 1]; const { max } = data; let startL = 1; let startR = 1; let endL = max; let endR = max; let dL = false; let dR = false; while (startL <= endL && startR <= endR) { const mid = startL < endL ? Math.max(Math.min(Math.floor((startL + endL) / 2), endL), startL) : Math.max(Math.min(Math.floor((startR + endR) / 2), endR), startR); Event.emit("process", { type: "bsycs", data: { type, subtype, p: mid } }); const data2 = await fl(type, subtype, mid); if (!data2) return [1, 1]; const { list } = data2; if (list.length == 0) return [1, 1]; const first = list[0].year; const last = list[list.length - 1].year; if (first > year && last < year) return [mid, mid]; if (last > year) { if (!dL) startL = Math.min(mid + 1, endL); if (!dR) startR = Math.min(mid + 1, endR); } else if (first < year) { if (!dL) endL = Math.max(mid - 1, startL); if (!dR) endR = Math.max(mid - 1, startR); } else if (first == last) { if (!dL) endL = Math.max(mid - 1, startL); if (!dR) startR = Math.min(mid + 1, endR); } else if (first == year) { startR = endR = mid; if (!dL) endL = Math.min(mid + 1, endR); } else if (last == year) { startL = endL = mid; if (!dR) startR = Math.min(mid + 1, endR); } if (startL == endL) dL = true; if (startR == endR) dR = true; if (dL && dR) return [startL, startR]; } return [1, 1]; } async function cbt(type, subtype, year = 0) { if (!year) return cbtAll(type, subtype); return cbtYear(type, subtype, year); } async function cbtYear(type, subtype, year) { const [start, end] = await bsycs(type, subtype, year); Event.emit("process", { type: "collZone", data: { zone: [start, end] } }); const ret = []; for (let i = start; i <= end; i++) { const data = await fl(type, subtype, i); if (data) ret.push(data.list); } return ret.flat(); } async function cbtAll(type, subtype) { const data = await fl(type, subtype, 1); if (!data) return []; const { list, max } = data; Event.emit("process", { type: "collZone", data: { zone: [1, max] } }); const ret = [list]; for (let i = 2; i <= max; i++) { const data2 = await fl(type, subtype, i); if (data2) ret.push(data2.list); } return ret.flat(); } async function collects({ type, subTypes, tag, year }) { const ret = []; for (const subtype of subTypes) { Event.emit("process", { type: "collSubtype", data: { subtype } }); const list = await cbt(type, subtype, year); ret.push(list); } const fset = /* @__PURE__ */ new Set(); return ret.flat().filter(({ id, year: y, tags }) => { if (year && year != y) return false; if (tag && !tags.includes(tag)) return false; if (fset.has(id)) return false; fset.add(id); return true; }).sort(({ time: a }, { time: b }) => b.getTime() - a.getTime()); } const css = '.v-report-btn{user-select:none;cursor:pointer}.v-report-btn.primary{background:#fc899488}.v-report-btn.primary:hover{background:#fc8994}.v-report-btn.danger{background:#fc222288}.v-report-btn.danger:hover{background:#fc2222}.v-report-btn.success{background:#22fc2288}.v-report-btn.success:hover{background:#22fc22}.v-report-btn.warning{background:#fcb12288}.v-report-btn.warning:hover{background:#fcb122}#kotori-report-canvas::-webkit-scrollbar,#kotori-report .scroll::-webkit-scrollbar{display:none}#kotori-report-menu:before{position:absolute;content:"菜单";padding:0 20px;top:-1px;right:-1px;left:-1px;height:30px;line-height:30px;background:#fc8994;backdrop-filter:blur(4px);border-radius:10px 10px 0 0}#kotori-report-menu{color:#fff;position:fixed;display:flex;flex-direction:column;top:50%;left:50%;transform:translate(-50%,-50%);padding:50px 20px 20px;background:#0d111788;backdrop-filter:blur(4px);border-radius:10px;box-shadow:2px 2px 10px #0008;border:1px solid #fc899422;min-width:150px;z-index:9999;>li:first-child{margin-top:0}>li{margin-top:10px;>.btn-group{display:flex;gap:10px;>.v-report-btn{width:100%;padding:10px 0;text-align:center;border-radius:5px;transition:all .3s;font-size:16px;font-weight:700}>.v-report-btn:hover{width:100%;padding:10px 0;text-align:center;border-radius:5px;transition:all .3s}}}>li:last-child{height:20px}fieldset{display:flex;gap:5px;min-inline-size:min-content;margin-inline:1px;border-width:1px;border-style:groove;border-color:threedface;border-image:initial;padding-block:.35em .625em;padding-inline:.75em;>div{display:flex;gap:2px;justify-content:center}}}#kotori-report{color:#fff;position:fixed;inset:0;z-index:9999;>.close{position:absolute;inset:0;background:#0000004d;backdrop-filter:blur(2px)}>.save{position:absolute;top:10px;right:10px;width:40px;height:40px;background:#fc8994;border-radius:40px;border:4px solid #fc8994;cursor:pointer;box-shadow:2px 2px 10px #0008;user-select:none;line-height:40px;background-size:40px;background-image:url(data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzMwIDMzMCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2NSwwQzc0LjAxOSwwLDAsNzQuMDE4LDAsMTY1YzAsOTAuOTgsNzQuMDE5LDE2NSwxNjUsMTY1czE2NS03NC4wMiwxNjUtMTY1QzMzMCw3NC4wMTgsMjU1Ljk4MSwwLDE2NSwweiBNMTY1LDMwMGMtNzQuNDM5LDAtMTM1LTYwLjU2MS0xMzUtMTM1UzkwLjU2MSwzMCwxNjUsMzBzMTM1LDYwLjU2MSwxMzUsMTM1UzIzOS40MzksMzAwLDE2NSwzMDB6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTIxMS42NjcsMTI3LjEyMWwtMzEuNjY5LDMxLjY2NlY3NWMwLTguMjg1LTYuNzE2LTE1LTE1LTE1Yy04LjI4NCwwLTE1LDYuNzE1LTE1LDE1djgzLjc4N2wtMzEuNjY1LTMxLjY2NmMtNS44NTctNS44NTctMTUuMzU1LTUuODU3LTIxLjIxMywwYy01Ljg1OCw1Ljg1OS01Ljg1OCwxNS4zNTUsMCwyMS4yMTNsNTcuMjcxLDU3LjI3MWMyLjkyOSwyLjkzLDYuNzY4LDQuMzk1LDEwLjYwNiw0LjM5NWMzLjgzOCwwLDcuNjc4LTEuNDY1LDEwLjYwNy00LjM5M2w1Ny4yNzUtNTcuMjcxYzUuODU3LTUuODU3LDUuODU4LTE1LjM1NSwwLjAwMS0yMS4yMTVDMjI3LjAyMSwxMjEuMjY0LDIxNy41MjQsMTIxLjI2NCwyMTEuNjY3LDEyNy4xMjF6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE5NSwyNDBoLTYwYy04LjI4NCwwLTE1LDYuNzE1LTE1LDE1YzAsOC4yODMsNi43MTYsMTUsMTUsMTVoNjBjOC4yODQsMCwxNS02LjcxNywxNS0xNUMyMTAsMjQ2LjcxNSwyMDMuMjg0LDI0MCwxOTUsMjQweiIvPjwvc3ZnPg==);opacity:.8;z-index:9999999999999}>.scroll{position:absolute;top:0;bottom:0;left:50%;transform:translate(-50%);overflow:scroll;>.content{display:flex;flex-direction:column;gap:5px;width:1078px;margin:0 auto;.banner{height:110px;background:#fc899488;backdrop-filter:blur(2px);color:#fff;text-shadow:0 0 5px #000;h1{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:36px;line-height:36px;text-align:center}.uid{position:absolute;top:5px;left:5px;font-size:20px}ul.bars{position:absolute;display:flex;flex-direction:column;justify-content:space-evenly;>li{position:relative;justify-content:center;>div:last-child{position:absolute;width:60px;top:50%;transform:translateY(-50%);height:3px;transition:all .3s;>div{position:absolute;top:0;height:100%;background:#fff}}}}ul.lb{align-items:flex-end;>li{>div:first-child{text-align:left;padding-left:65px}>div:last-child{left:0;>div{right:0}}}}ul.rb{align-items:flex-start;>li{>div:first-child{text-align:right;padding-right:65px}>div:last-child{right:0;>div{left:0}}}}ul.total-time{font-family:consolas,courier new,monospace,courier;bottom:0;left:0;>li>div:first-child{width:150px}}ul.includes{top:0;right:0;>li>div:first-child{width:80px}}}ul.year-cover{display:flex;flex-direction:column;gap:5px;>li{position:relative;>h2{position:relative;padding:2px;text-align:center;background:#fc899488;backdrop-filter:blur(2px);color:#fff;font-weight:700;text-shadow:0 0 4px #000;>span{position:absolute;top:50%;right:10px;transform:translateY(-50%);font-size:14px;color:#ffde20}}}>li:before{content:"";display:block;position:absolute;inset:0;border:1px solid #fc8994;box-sizing:border-box}}>.bar-group{display:flex;justify-content:space-between;align-items:flex-end;ul.bars{display:flex;flex-direction:column;gap:2px;position:relative;width:calc(50% - 1px);>li{display:block;position:relative;width:100%;height:20px;background:#0008;margin:0;line-height:20px;backdrop-filter:blur(2px);>span{position:absolute;left:5px;text-shadow:0 0 2px #000}>span:nth-child(2){position:absolute;left:50%;transform:translate(-50%)}>div{display:inline-block;height:100%;background:#fc8994aa;margin:0}}}}ul.covers[type=music]>li{height:150px}ul.covers{line-height:0;>li{display:inline-block;position:relative;width:150px;height:220px;margin:2px;overflow:hidden;border-width:1px;border-style:solid;border-color:#fc8994;box-sizing:border-box;img{max-height:100%;position:absolute;top:0;left:50%;transform:translate(-50%)}>span{width:50px;height:30px;position:absolute;top:0;left:0;line-height:30px;text-align:center;font-size:18px;background:#8c49548c;backdrop-filter:blur(2px)}.star{display:block;position:absolute;bottom:3px;right:3px;width:20px;height:20px;padding:5px;background:none;>img{opacity:.85}>span{position:absolute;top:50%;left:50%;color:#f4a;font-family:consolas,courier new,monospace,courier;font-size:18px;font-weight:700;text-shadow:0 0 2px #fff;transform:translate(-50%,-50%)}}}}}}}#kotori-report-canvas{color:#fff;position:fixed;inset:0;z-index:9999;background:#0000004d;backdrop-filter:blur(2px);overflow:scroll;padding:30px;scrollbar-width:none;-ms-overflow-style:none;>div{position:absolute;inset:0;background:#0000004d;backdrop-filter:blur(2px)}>canvas{position:absolute;top:0;left:50%;transform:translate(-50%)}}@media screen and (min-width: 616px){#kotori-report .content{width:616px!important}}@media screen and (min-width: 830px){#kotori-report .content{width:770px!important}}@media screen and (min-width: 924px){#kotori-report .content{width:924px!important}}@media screen and (min-width: 1138px){#kotori-report .content{width:1078px!important}}'; const Star = "data:image/svg+xml,%3csvg%20fill='%23ffb300'%20width='800px'%20height='800px'%20viewBox='43%20159.5%2021%2021'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3cpath%20d='M60.556381,172.206%20C60.1080307,172.639%2059.9043306,173.263%2060.0093306,173.875%20L60.6865811,177.791%20C60.8976313,179.01%2059.9211306,180%2058.8133798,180%20C58.5214796,180%2058.2201294,179.931%2057.9282291,179.779%20L54.3844766,177.93%20C54.1072764,177.786%2053.8038262,177.714%2053.499326,177.714%20C53.1958758,177.714%2052.8924256,177.786%2052.6152254,177.93%20L49.0714729,179.779%20C48.7795727,179.931%2048.4782224,180%2048.1863222,180%20C47.0785715,180%2046.1020708,179.01%2046.3131209,177.791%20L46.9903714,173.875%20C47.0953715,173.263%2046.8916713,172.639%2046.443321,172.206%20L43.575769,169.433%20C42.4480682,168.342%2043.0707186,166.441%2044.6289197,166.216%20L48.5916225,165.645%20C49.211123,165.556%2049.7466233,165.17%2050.0227735,164.613%20L51.7951748,161.051%20C52.143775,160.35%2052.8220755,160%2053.499326,160%20C54.1776265,160%2054.855927,160.35%2055.2045272,161.051%20L56.9769285,164.613%20C57.2530787,165.17%2057.7885791,165.556%2058.4080795,165.645%20L62.3707823,166.216%20C63.9289834,166.441%2064.5516338,168.342%2063.423933,169.433%20L60.556381,172.206%20Z'%3e%3c/path%3e%3c/svg%3e"; addStyle(css); const PRG = ["|", "/", "-", "\\"]; async function showCanvas(element) { const canvas = await element2Canvas(element); const close = create("div", { style: { height: canvas.style.height } }); const main = create("div", { id: "kotori-report-canvas" }, close, canvas); close.addEventListener("click", () => main.remove()); document.body.appendChild(main); } function pw(v, m) { return { style: { width: v * 100 / m + "%" } }; } function buildTotalTime({ total, normal, guess, unknown }) { const list = [total, normal, guess, unknown].sort((a, b) => b.time - a.time); const format = ({ name, count, time }) => `${timeFormat(time, true)} (${count})${name}`; const buildItem = (item) => [ "li", ["div", format(item)], ["div", ["div", pw(item.time, total.time)]] ]; return ["ul", { class: ["total-time", "bars", "rb"] }, ...list.map(buildItem)]; } function buildIncludes(list, type) { const l = Array.from(list).map(([k, v]) => [formatSubType(k, type), v]); const total = l.reduce((sum, [_, v]) => sum + v, 0); l.unshift(["总计", total]); l.sort((a, b) => b[1] - a[1]); const format = (k, v) => k + ":" + ("" + v).padStart(5, " ") + Types[type].unit; const buildItem = ([k, v]) => [ "li", ["div", format(k, v)], ["div", ["div", pw(v, total)]] ]; return ["ul", { class: ["includes", "bars", "lb"] }, ...l.map(buildItem)]; } function buildBarList(list) { const l = Array.from(list).sort(([, , a], [, , b]) => a - b); const m = Math.max(...l.map(([v]) => v)); const buildItem = ([v, t]) => ["li", ["span", t], ["span", v], ["div", pw(v, m)]]; return ["ul", { class: "bars" }, ...l.map(buildItem)]; } function buildCoverList(list, type) { let last = -1; const covers = []; for (const { img, month, star } of list) { const childs = [["img", { src: img }]]; if (month != last) { childs.push(["span", month + 1 + "月"]); last = month; } if (star) childs.push([ "div", { class: "star" }, ["img", { src: Star }], ["span", star] ]); covers.push(["li", ...childs]); } return ["ul", { class: "covers", type }, ...covers]; } async function buildLifeTimeReport({ type, tag, subTypes, totalTime: ttt }) { const list = await collects({ type, subTypes, tag }); const time = ttt ? await totalTime(list) : null; const buildYearCover = ([year, l]) => ["li", ["h2", year + "年", ["span", l.length]], buildCoverList(l, type)]; const banner = [ "div", { class: "banner" }, ["h1", `Bangumi ${Types[type].name}生涯总览`], ["span", { class: "uid" }, "@" + uid], buildIncludes(groupCount(list, "subType").entries(), type) ]; if (time) banner.push(buildTotalTime(time)); const countList = buildBarList( groupCount(list, "month", countMap(12)).entries().map(([k, v]) => [v, k + 1 + "月", k]) ); const starList = buildBarList( groupCount(list, "star", countMap(11)).entries().map(([k, v]) => [v, k ? k + "星" : "未评分", k]) ); const barGroup = ["div", { class: "bar-group" }, countList, starList]; const yearCover = [ "ul", { class: "year-cover" }, ...groupBy(list, "year").entries().map(buildYearCover) ]; return create("div", { class: "content" }, banner, barGroup, yearCover); } async function buildYearReport({ year, type, tag, subTypes, totalTime: t }) { const list = await collects({ type, subTypes, tag, year }); const time = t ? await totalTime(list) : null; const banner = [ "div", { class: "banner" }, ["h1", `${year}年 Bangumi ${Types[type].name}年鉴`], ["span", { class: "uid" }, "@" + uid], buildIncludes(groupCount(list, "subType").entries(), type) ]; if (time) banner.push(buildTotalTime(time)); const countList = buildBarList( groupCount(list, "month", countMap(12)).entries().map(([k, v]) => [v, k + 1 + "月", k]) ); const starList = buildBarList( groupCount(list, "star", countMap(11)).entries().map(([k, v]) => [v, k ? k + "星" : "未评分", k]) ); const barGroup = ["div", { class: "bar-group" }, countList, starList]; return create("div", { class: "content" }, banner, barGroup, buildCoverList(list, type)); } async function buildReport(options) { Event.emit("process", { type: "start", data: options }); const content = await (options.isLifeTime ? buildLifeTimeReport(options) : buildYearReport(options)); Event.emit("process", { type: "done" }); const close = create("div", { class: "close" }); const scroll = create("div", { class: "scroll" }, content); const save = create("div", { class: "save" }); const report = create("div", { id: "kotori-report" }, close, scroll, save); const saveFn = async () => { save.onclick = null; await showCanvas(content); save.onclick = saveFn; }; let ly = scroll.scrollTop || 0; let my = ly; let ey = ly; let interval = 0; const scrollFn = (iey) => { ey = Math.max(Math.min(iey, scroll.scrollHeight - scroll.offsetHeight), 0); ly = my; if (interval) clearInterval(interval); let times = 1; interval = setInterval(() => { if (times > 50) { clearInterval(interval); interval = 0; return; } my = easeOut(times, ly, ey, 50); scroll.scroll({ top: my }); times++; }, 1); }; const wheelFn = (e) => { e.preventDefault(); scrollFn(ey + e.deltaY); }; const keydownFn = (e) => { e.preventDefault(); if (e.key == "Escape") close.click(); if (e.key == "Home") scrollFn(0); if (e.key == "End") scrollFn(scroll.scrollHeight - scroll.offsetHeight); if (e.key == "ArrowUp") scrollFn(ey - 100); if (e.key == "ArrowDown") scrollFn(ey + 100); if (e.key == "PageUp") scrollFn(ey - scroll.offsetHeight); if (e.key == "PageDown") scrollFn(ey + scroll.offsetHeight); }; scroll.addEventListener("wheel", wheelFn); close.addEventListener("wheel", wheelFn); save.addEventListener("wheel", wheelFn); document.addEventListener("keydown", keydownFn); save.addEventListener("click", saveFn); close.addEventListener("click", () => { document.removeEventListener("keydown", keydownFn); report.remove(); }); document.body.appendChild(report); } function buildMenu() { const year = (/* @__PURE__ */ new Date()).getFullYear(); const yearSelectOptions = new Array(year - 2007).fill(0).map((_, i) => ["option", { value: "" + (year - i) }, year - i]); const lifeTimeCheck = create("input", { type: "checkbox", id: "lftc" }); const totalTimeCheck = create("input", { type: "checkbox", id: "tltc" }); const yearSelect = create("select", {}, ...yearSelectOptions); const typeSelect = create( "select", {}, ...Object.entries(Types).map( ([_, { value, name }]) => ["option", { value }, name] ) ); const tagSelect = create("select", ["option", { value: "" }, "不筛选"]); const btnGo = create("div", { class: ["v-report-btn", "primary"] }, "生成"); const btnClr = create("div", { class: ["v-report-btn", "v-report", "warning"] }, "清理缓存"); const btnGroup = ["div", { class: "btn-group" }, btnGo, btnClr]; const additionField = [ "fieldset", ["legend", "附加选项"], ["div", lifeTimeCheck, ["label", { htmlFor: "lftc" }, "生涯报告"]], ["div", totalTimeCheck, ["label", { htmlFor: "tltc" }, "看过时长(耗时)"]] ]; const ytField = [ "fieldset", ["legend", "选择年份与类型"], yearSelect, typeSelect ]; const tagField = ["fieldset", ["legend", "选择过滤标签"], tagSelect]; const subtypeField = create( "fieldset", ["legend", "选择包括的状态"], ...Object.entries(SubTypes).map( ([_, { value, name, checked }]) => [ "div", { "data-value": value }, [ "input", { type: "checkbox", id: "yst_" + value, name, value, checked } ], ["label", { htmlFor: "yst_" + value }, name] ] ) ); const eventInfo = create("li"); const menu2 = create( "ul", { id: "kotori-report-menu" }, ["li", additionField], ["li", ytField], ["li", tagField], ["li", subtypeField], ["li", btnGroup], eventInfo ); Event.on( "process", /* @__PURE__ */ (() => { let type; let zone = [0, 0]; let subtype; let subtypes; let pz = false; let totalTimeCount = 0; return ({ type: t, data }) => { switch (t) { case "start": type = data.type; subtypes = data.subTypes; eventInfo.innerText = ""; pz = false; break; case "collSubtype": subtype = data.subtype; pz = false; break; case "bsycs": eventInfo.innerText = `二分搜索[${formatSubType(subtype, type)}] (${data.p})`; break; case "collZone": zone = data.zone; pz = true; break; case "parse": if (!pz) return; eventInfo.innerText = `正在解析[${formatSubType(subtype, type)}] (` + (data.p - zone[0] + 1) + "/" + (zone[1] - zone[0] + 1) + ")(" + (subtypes.indexOf(subtype) + 1) + "/" + subtypes.length + ")"; break; case "done": eventInfo.innerText = ""; pz = false; break; case "tags": eventInfo.innerText = `获取标签 [${Types[data.type].name}]`; break; case "totalTime": totalTimeCount = data.total; break; case "totalTimeItem": eventInfo.innerText = `获取条目时长 (${data.count}/${totalTimeCount}) (id: ${data.id})`; break; default: return; } }; })() ); lifeTimeCheck.addEventListener("change", () => { if (lifeTimeCheck.checked) yearSelect.disabled = true; else yearSelect.disabled = false; }); typeSelect.addEventListener( "change", callNow(async () => { const type = typeSelect.value; if (!type) return; totalTimeCheck.disabled = type !== "anime"; subtypeField.querySelectorAll("div").forEach((e) => { const name = formatSubType(e.getAttribute("data-value"), type); e.querySelector("input").setAttribute("name", name); e.querySelector("label").innerText = name; }); const tags = await ft(type); if (type != typeSelect.value) return; const last = tagSelect.value; removeAllChildren(tagSelect); tagSelect.append(create("option", { value: "" }, "不筛选")); append( tagSelect, ...tags.map((t) => ["option", { value: t }, t]) ); if (tags.includes(last)) tagSelect.value = last; }) ); btnGo.addEventListener( "click", callWhenDone(async () => { const type = typeSelect.value || "anime"; await buildReport({ type, subTypes: Array.from( subtypeField.querySelectorAll("input:checked") ).map((e) => e.value), isLifeTime: lifeTimeCheck.checked, totalTime: type === "anime" && totalTimeCheck.checked, year: parseInt(yearSelect.value) || year, tag: tagSelect.value }); menuToggle(); }) ); btnClr.addEventListener( "click", callWhenDone(async () => { let i = 0; const id = setInterval(() => btnClr.innerText = `清理缓存中[${PRG[i++ % 4]}]`, 50); await db.clear("pages"); clearInterval(id); btnClr.innerText = "清理缓存"; }) ); document.body.appendChild(menu2); return menu2; } let menu = null; function menuToggle() { menu ??= buildMenu(); menu.style.display = menu.style.display == "block" ? "none" : "block"; } (async () => { await db.init(); const btn = create( "a", { class: "chiiBtn", href: "javascript:void(0)", title: "生成年鉴" }, ["span", "生成年鉴"] ); btn.addEventListener("click", menuToggle); document.querySelector("#headerProfile .actions").append(btn); })(); })();