// ==UserScript== // @name Bangumi 年鉴 // @description 根据Bangumi的时光机数据生成年鉴 // @namespace syaro.io // @version 1.2.2 // @author 神戸小鳥 @vickscarlet // @license MIT // @include /^https?://(bgm\.tv|chii\.in|bangumi\.tv)\/(user)\/.*/ // @downloadURL none // ==/UserScript== (async () => { const origin = window.location.origin; const uid = window.location.href.match(/\/user\/(.+)?(\/.*)?/)[1]; const year = new Date().getFullYear(); const ce = name => document.createElement(name); const Types = { anime: '动画', game: '游戏', music: '音乐', book: '图书', real: '三次元', } const SubTypes = [ { value: 'collect', name: '看过', checked: true }, { value: 'do', name: '在看', checked: false }, { value: 'dropped', name: '抛弃', checked: false }, { value: 'on_hold', name: '搁置', checked: false }, { value: 'wish', name: '想看', checked: false }, ]; // indexedDB cache class DB { constructor() { } #dbName = 'mcache'; #version = 1; #collection = 'pages'; #keyPath = 'url'; #db; async init() { this.#db = await new Promise((resolve, reject) => { const request = window.indexedDB.open(this.#dbName, this.#version); request.onerror = event => reject(event.target.error); request.onsuccess = event => resolve(event.target.result); request.onupgradeneeded = event => { if (event.target.result.objectStoreNames.contains(this.#collection)) return; event.target.result.createObjectStore(this.#collection, { keyPath: this.#keyPath }); }; }); } async #store(handle, mode = 'readonly') { return new Promise((resolve, reject) => { const transaction = this.#db.transaction(this.#collection, mode); const store = transaction.objectStore(this.#collection); let result; new Promise((rs, rj) => handle(store, rs, rj)) .then(ret => result = ret) .catch(reject); transaction.onerror = () => reject(new Error('Transaction error')); transaction.oncomplete = () => resolve(result); }); } async get(key, index) { return this.#store((store, resolve, reject) => { if (index) store = store.index(index); const request = store.get(key); request.onerror = reject; request.onsuccess = () => resolve(request.result); }) .catch(null); } async put(data) { return this.#store((store, resolve, reject) => { const request = store.put(data); request.onerror = reject; request.onsuccess = () => resolve(true); }, 'readwrite') .catch(false); } } const db = new DB(); await db.init(); const f = async url => { const html = await fetch(url).then(res => res.text()); if (html.match(/503 Service Temporarily Unavailable/)) return null; const e = ce('html'); e.innerHTML = html.replace(//g, ''); return e; }; const fl = async (type, subtype, p = 1, expire = 30) => { const url = `${origin}/${type}/list/${uid}/${subtype}?page=${p}`; let data = await db.get(url); if (data && data.time + expire * 60000 > Date.now()) return data; const e = await f(`${origin}/${type}/list/${uid}/${subtype}?page=${p}`, 30); const list = Array .from(e.querySelectorAll('#browserItemList > li')) .map(li => { const data = { subtype }; data.id = li.querySelector('a').href.split('/').pop(); const title = li.querySelector('h3'); data.title = title.querySelector('a').innerText; data.jp_title = title.querySelector('small')?.innerText; data.img = li.querySelector('span.img') ?.getAttribute('src').replace('cover/c', 'cover/l') || '//bgm.tv/img/no_icon_subject.png'; data.time = new Date(li.querySelector('span.tip_j').innerText); data.year = data.time.getFullYear(); data.month = data.time.getMonth(); data.star = parseInt(li.querySelector('span.starlight')?.className.match(/stars(\d{1,2})/)[1]) || 0; data.tags = li.querySelector('span.tip')?.textContent.trim().match(/标签:\s*(.*)/)?.[1].split(/\s+/) || []; return data; }); const max = Number(e.querySelector('span.p_edge')?.textContent.match(/\/\s*(\d+)\s*\)/)?.[1] || 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(data); return data; } const ft = async (type) => fl(type, 'collect').then(({ tags }) => tags) const bsycs = async (type, subtype, year) => { const { max } = await fl(type, subtype); console.info('Total', type, subtype, max, 'page'); console.info('BSearch by year', year); 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) const { list } = await fl(type, subtype, mid); if (list.length == 0) return [1, 1]; const first = list[0].year; const last = list[list.length - 1].year; console.info(`\tBSearch page`, mid, ' ', '\t[', first, last, ']'); 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 (!dL) startR = Math.min(mid + 1, endR); } if (startL == endL) dL = true; if (startR == endR) dR = true; if (dL && dR) return [startL, startR]; } } const cbt = async (type, subtype, year) => { const [start, end] = await bsycs(type, subtype, year); console.info('Collect pages [', start, end, ']'); const ret = []; for (let i = start; i <= end; i++) { console.info('\tCollect page', i); const { list } = await fl(type, subtype, i); ret.push(list); } return ret.flat(); }; const collects = async (type, year, subtypes) => { const ret = []; for (const subtype of subtypes) { const list = await cbt(type, subtype, year); ret.push(list); } const fset = new Set(); return ret.flat() .filter(({ id }) => { if (fset.has(id)) return false; fset.add(id); return true; }) .sort(({ time: a }, { time: b }) => b - a); } const menu = ce('ul'); document.body.appendChild(menu); const ma = name => menu.appendChild(ce('li')).appendChild(ce(name)); menu.id = 'kotori-report-menu'; const msw = { _: true, get() { return this._ }, set(v) { this._ = v; menu.style.display = v ? 'block' : 'none'; }, toggle() { this.set(!this.get()); }, }; msw.toggle(); const btn = ce('a'); btn.onclick = () => msw.set(true); btn.className = 'chiiBtn'; btn.href = 'javascript:void(0)'; btn.title = '生成年鉴'; btn.innerHTML = '生成年鉴 ``).join(''); const typeSelect = ce('select'); typeSelect.innerHTML = Object.entries(Types) .map(([value, name]) => ``).join(''); ytField.appendChild(yearSelect); ytField.appendChild(typeSelect); const tagField = ma('fieldset'); tagField.innerHTML = '选择过滤标签'; const tagSelect = ce('select'); tagField.appendChild(tagSelect); tagSelect.innerHTML = ``; const changeType = async () => { const type = typeSelect.value; const tags = await ft(type); if (type != typeSelect.value) return; const last = tagSelect.value; const options = tags.map(t => ``).join(''); tagSelect.innerHTML = `${options}`; if (tags.includes(last)) tagSelect.value = last; }; typeSelect.onchange = changeType; changeType(); const subtypeField = ma('fieldset'); subtypeField.innerHTML = '选择包括的状态' + SubTypes .map(({ value, name, checked }) => `
`) .join(''); let html2canvasloaded = false; const saveImage = (e, d) => { const done = () => { html2canvasloaded = true; html2canvas(e, { 'allowTaint': true, 'logging': false, 'backgroundColor': '#1c1c1c' }).then(canvas => { const div = ce('div'); div.id = 'kotori-report-canvas'; div.appendChild(ce('div')).onclick = () => div.remove(); div.appendChild(canvas); document.body.appendChild(div); d(); }); }; if (html2canvasloaded) return done(); const script = ce('script'); script.type = 'text/javascript'; script.src = 'https://html2canvas.hertzen.com/dist/html2canvas.min.js'; script.onload = done; document.body.appendChild(script); } const go = ma('div'); go.className = 'btn'; go.innerText = '生成'; const l = ['|', '/', '-', '\\']; const gen = async () => { go.onclick = null; let i = 0; const id = setInterval(() => go.innerText = `抓取数据中[${l[i++ % 4]}]`, 50); const y = parseInt(yearSelect.value) || year; const t = typeSelect.value || 'anime'; const g = tagSelect.value; const sts = Array.from(subtypeField.querySelectorAll('input:checked')).map(e => e.value) const list = await collects(t, y, sts); go.onclick = gen; clearInterval(id); go.innerText = '生成'; const filterList = list.filter(({ year, tags }) => year == y && (!g || g && tags.includes(g))); msw.set(false); let count = new Array(12).fill(0); const stars = new Array(11).fill(0); let last = -1; const lis = []; for (const { img, month, star } of filterList) { count[month]++; stars[star]++; let monthTag = ''; if (month != last) { monthTag = ` ${month + 1}月 `; last = month; } lis.push(`
  • ${monthTag}
  • `); } const eT = `

    ${y}年 Bangumi ${Types[t]}年鉴 @${uid}

    总标记数:${filterList.length}

    `; const eU = ``; const bU = (l, t, d = 0) => { const max = Math.max(...l); l = l.map((c, i) => `
  • ${i + d}${t}${c}
  • ` ).join(''); return ``; } const content = ce('div'); content.className = 'content'; content.innerHTML = [ eT, bU(count, '月', 1), bU(stars, '星'), eU ].join(''); const close = ce('div'); close.className = 'close'; close.onclick = () => div.remove(); const save = ce('div'); save.className = 'save'; const s = () => { save.onclick = null; saveImage(content, () => save.onclick = s) }; save.onclick = s; const div = ce('div'); div.appendChild(close); div.appendChild(content); div.appendChild(save); div.id = 'kotori-report'; document.body.appendChild(div); }; go.onclick = gen; document.querySelector('#headerProfile .actions').append(btn); // style const style = ce('style'); document.head.appendChild(style); style.innerHTML = ` .btn { user - select: none; cursor: pointer; } #kotori-report-menu { color: #fff; position: fixed; display: block; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 20px; padding-top: 50px; background: #0d111788; backdrop-filter: blur(4px); border-radius: 10px; box-shadow: 2px 2px 10px #00000088; border: 1px solid #fc899422; min-width: 150px; } #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 > li { margin - top: 10px; } #kotori-report-menu > li:first-child { margin - top: 0; } #kotori-report-menu > li > .btn { width: 100%; padding: 10px 0; background: #fc899444; border: inset 2px solid #fc8994; text-align: center; border-radius: 5px; transition: all 0.3s; font-family: consolas, 'courier new', monospace, courier; } #kotori-report-menu > li > .btn:hover { width: 100%; padding: 10px 0; background: #fc8994; border: 2px solid #fc8994 inset; text-align: center; border-radius: 5px; transition: all 0.3s; } #kotori-report-menu 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: 0.35em 0.625em; padding-inline: 0.75em; } #kotori-report-menu fieldset > div { display: flex; gap: 2px; justify-content: center; } #kotori-report-canvas, #kotori-report { color: #fff; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); backdrop-filter: blur(2px); overflow: scroll; padding: 30px; scrollbar-width: none; -ms-overflow-style: none; } #kotori-report-canvas::-webkit-scrollbar, #kotori-report::-webkit-scrollbar { display: none; } #kotori-report-canvas > div, #kotori-report > .close { position: absolute; top: 0; right: 0; left: 0; bottom: 0; } #kotori-report > .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 #00000088; user-select: none; line-height: 40px; background-size: 40px; background-image: url(data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzMwIDMzMCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2NSwwQzc0LjAxOSwwLDAsNzQuMDE4LDAsMTY1YzAsOTAuOTgsNzQuMDE5LDE2NSwxNjUsMTY1czE2NS03NC4wMiwxNjUtMTY1QzMzMCw3NC4wMTgsMjU1Ljk4MSwwLDE2NSwweiBNMTY1LDMwMGMtNzQuNDM5LDAtMTM1LTYwLjU2MS0xMzUtMTM1UzkwLjU2MSwzMCwxNjUsMzBzMTM1LDYwLjU2MSwxMzUsMTM1UzIzOS40MzksMzAwLDE2NSwzMDB6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTIxMS42NjcsMTI3LjEyMWwtMzEuNjY5LDMxLjY2NlY3NWMwLTguMjg1LTYuNzE2LTE1LTE1LTE1Yy04LjI4NCwwLTE1LDYuNzE1LTE1LDE1djgzLjc4N2wtMzEuNjY1LTMxLjY2NmMtNS44NTctNS44NTctMTUuMzU1LTUuODU3LTIxLjIxMywwYy01Ljg1OCw1Ljg1OS01Ljg1OCwxNS4zNTUsMCwyMS4yMTNsNTcuMjcxLDU3LjI3MWMyLjkyOSwyLjkzLDYuNzY4LDQuMzk1LDEwLjYwNiw0LjM5NWMzLjgzOCwwLDcuNjc4LTEuNDY1LDEwLjYwNy00LjM5M2w1Ny4yNzUtNTcuMjcxYzUuODU3LTUuODU3LDUuODU4LTE1LjM1NSwwLjAwMS0yMS4yMTVDMjI3LjAyMSwxMjEuMjY0LDIxNy41MjQsMTIxLjI2NCwyMTEuNjY3LDEyNy4xMjF6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE5NSwyNDBoLTYwYy04LjI4NCwwLTE1LDYuNzE1LTE1LDE1YzAsOC4yODMsNi43MTYsMTUsMTUsMTVoNjBjOC4yODQsMCwxNS02LjcxNywxNS0xNUMyMTAsMjQ2LjcxNSwyMDMuMjg0LDI0MCwxOTUsMjQweiIvPjwvc3ZnPg==); opacity: 0.8; z-index: 9999999999999; } #kotori-report > .content { width: 1078px; margin: 0 auto; } #kotori-report > .content > h1 { padding: 30px 0; text-align: center; } #kotori-report > .content > ul.l > li { display: inline-block; position: relative; width: 150px; height: 215px; background: #0008; margin: 2px; } #kotori-report > .content > ul.l > li 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); } #kotori-report > .content > ul.l > li img { max - width: 150px; max-height: 220px; position: absolute; top: 0; left: 50%; transform: translateX(-50%); } #kotori-report > .content > ul.c { display: inline-block; position: relative; width: calc(50% - 4px); margin: 2px; } #kotori-report > .content > ul.c > li { display: block; position: relative; width: 100%; height: 20px; background: #0008; margin: 2px; line-height: 20px; backdrop-filter: blur(2px); } #kotori-report > .content > ul.c > li > span { position: absolute; left: 0; text-shadow: 0 0 2px #000; } #kotori-report > .content > ul.c > li > span:nth-child(2) { position: absolute; left: 50%; transform: translateX(-50%); } #kotori-report > .content > ul.c > li > div { display: inline-block; height: 100%; background: #fc8994aa; margin: 0; } #kotori-report-canvas > canvas { position: absolute; top: 0; left: 50%; transform: translateX(-50%) scale(0.8); } @media screen and (min-width: 214px) { #kotori - report > .content { width: 154px; } } @media screen and (min-width: 368px) { #kotori - report > .content { width: 308px; } } @media screen and (min-width: 522px) { #kotori - report > .content { width: 462px; } } @media screen and (min-width: 616px) { #kotori - report > .content { width: 616px; } } @media screen and (min-width: 830px) { #kotori - report > .content { width: 770px; } } @media screen and (min-width: 924px) { #kotori - report > .content { width: 924px; } } @media screen and (min-width: 1138px) { #kotori - report > .content { width: 1078px; } } `; })();