Annictの作品詳細ページにdアニメストア ニコニコ支店のリンクを追加する
// ==UserScript== // @name Annict dアニメストア ニコニコ支店 // @namespace https://midra.me // @version 1.0.8 // @description Annictの作品詳細ページにdアニメストア ニコニコ支店のリンクを追加する // @author Midra // @match https://annict.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=annict.com // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @connect site.nicovideo.jp // @run-at document-body // ==/UserScript== (async () => { 'use strict' const ANNICT_EXT = { request: { config: { targetUrl: 'https://site.nicovideo.jp/danime/static/data/list.json', }, async getDanimeList() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: this.config.targetUrl, responseType: 'json', onload: e => resolve(e.response), onerror: e => reject(e), }) }) }, }, cache: { _cache_key: '_mid_danimeList_cache', _lastupdated_key: '_mid_danimeList_lastUpdated', /** * @returns {Promise<{ title: string; url: string; }[]> | undefined} */ get() { try { const cache = JSON.parse(GM_getValue(this._cache_key)) return Object.keys(cache).length !== 0 ? cache : void 0 } catch(e) { console.error(e) } }, /** * @returns {number} */ getLastUpdated() { return new Date(Number(GM_getValue(this._lastupdated_key))).getTime() }, /** * @param {{ title: string; url: string; col_key: string; }[]} data * @returns {boolean} */ set(data) { if (Array.isArray(data) && data.length !== 0) { data.forEach(v => delete v['col_key']) data.sort((a, b) => a.title < b.title ? -1 : b.title < a.title ? 1 : 0) GM_setValue(this._cache_key, JSON.stringify(data)) GM_setValue(this._lastupdated_key, new Date().getTime()) return true } return false }, reset() { GM_deleteValue('_mid_danimeList_cache') GM_deleteValue('_mid_danimeList_lastUpdated') }, async update() { const data = await ANNICT_EXT.request.getDanimeList() if (this.set(data)) { console.log('「dアニメストア ニコニコ支店」の作品リストを更新しました。') } else { console.error('「dアニメストア ニコニコ支店」の作品リストの更新に失敗しました。') } }, isOld(period_h = 24) { const now = new Date().getTime() const lastUpdated = this.getLastUpdated() return (now - lastUpdated) >= (period_h * 216000) }, }, /** * @returns {Promise<{ title: string; url: string; }[]> | undefined} */ async getList() { let data = this.cache.get() if (data === void 0 || this.cache.isOld()) { await this.cache.update() data = this.cache.get() || data } return data }, /** * @param {string} title * @returns {Promise<{ title: string; url: string; } | { title: string; url: string; }[] | undefined>} */ async getMatchItems(title) { const list = await this.getList() if (list === void 0) return const annictTitle = this.normalizeTitle(title) if (annictTitle === '') return /** * @type {{ item: { title: string; url: string; } | null; items: { title: string; url: string; }[] }} */ const r###lt = list.reduce((r###lt, item) => { const itemTitle = this.normalizeTitle(item.title) if (itemTitle === annictTitle) { r###lt.item = item } else if (Math.min(itemTitle.length, annictTitle.length) / Math.max(itemTitle.length, annictTitle.length) > 0.65) { const idxA = itemTitle.indexOf(annictTitle) const idxB = annictTitle.indexOf(itemTitle) const idx = Math.max(idxA, idxB) if (idx !== -1) { r###lt.items.push({ idx, ...item }) } } return r###lt }, { item: null, items: [] }) console.log({ item: r###lt.item, items: [...r###lt.items] }) if (r###lt.item !== null) { return r###lt.item } else if (r###lt.items.length !== 0) { r###lt.items.sort((a, b) => a.idx < b.idx ? -1 : b.idx < a.idx ? 1 : 0) return r###lt.items // return r###lt.items.pop().url // return r###lt.items[0]?.url } }, normalizeTitle(title = '') { return title.toLowerCase() .replace(/[\s-−\(\)()「」「」『』【】[]〈〉《》〔〕{}{}\[\]]/g, '') .replace(/[a-z0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0)) .replace(/./g, s => ({ '〜': '~', '?': '?', '!': '!', '”': '"', '’': "'", '´': "'", '`': '`', ':': ':', ',': ',', '.': '.', '・': '・', '/': '/', '#': '#', '$': '$', '%': '%', '&': '&', '=': '=', '@': '@', }[s] || s)) }, generateStreamingLink(url, text) { const item = document.createElement('li') item.classList.add('list-inline-item', 'mt-2') const link = document.createElement('a') link.classList.add('btn', 'btn-outline-primary', 'btn-sm', 'rounded-pill', 'danime-niconico-added') link.href = url link.target = '_blank' link.rel = 'noopener' link.insertAdjacentText('afterbegin', text) link.insertAdjacentHTML('beforeend', '<svg class="svg-inline--fa fa-external-link-alt fa-w-16 ms-1 small" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="external-link-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" data-fa-i2svg=""><path fill="currentColor" d="M432,320H400a16,16,0,0,0-16,16V448H64V128H208a16,16,0,0,0,16-16V80a16,16,0,0,0-16-16H48A48,48,0,0,0,0,112V464a48,48,0,0,0,48,48H400a48,48,0,0,0,48-48V336A16,16,0,0,0,432,320ZM488,0h-128c-21.37,0-32.05,25.91-17,41l35.73,35.73L135,320.37a24,24,0,0,0,0,34L157.67,377a24,24,0,0,0,34,0L435.28,133.32,471,169c15,15,41,4.5,41-17V24A24,24,0,0,0,488,0Z"></path></svg>') item.appendChild(link) return item }, async init() { if (this.cache.isOld()) { await this.cache.update() } }, } ANNICT_EXT.init() let timeout = null const addLink = () => { if (timeout !== null) { clearTimeout(timeout) } if (!location.href.startsWith('https://annict.com/works/')) return timeout = setTimeout(async () => { const title = document.querySelector('.c-work-header h1.fw-bold.h2.mt-1 > a.text-body') const linkContainer = document.querySelector('.c-work-header ul.list-inline.mb-0') let streamingLinkContainer = document.querySelector('.c-work-header ul.list-inline.mt-2') const hasDanimeLink = Array.from(streamingLinkContainer?.children || []).find(v => v.textContent.indexOf('dアニメストア ニコニコ支店') !== -1) !== undefined if (hasDanimeLink || title === null || streamingLinkContainer === null && linkContainer === null) return const items = await ANNICT_EXT.getMatchItems(title.textContent) console.log(items) if (items !== void 0) { if (streamingLinkContainer === null) { linkContainer.insertAdjacentHTML('beforebegin', `<ul class="list-inline mt-2"></ul>` ) streamingLinkContainer = document.querySelector('.c-work-header ul.list-inline.mt-2') } if (Array.isArray(items)) { items.forEach(item => { streamingLinkContainer.appendChild( ANNICT_EXT.generateStreamingLink(item.url, `dアニメストア ニコニコ支店 (${item.title})`) ) }) } else { streamingLinkContainer.appendChild( ANNICT_EXT.generateStreamingLink(items.url, 'dアニメストア ニコニコ支店') ) } } timeout = null }, 200) } const obs = new MutationObserver(mutationList => { Array.from(mutationList).forEach(mutation => { Array.from(mutation.addedNodes).forEach(added => { if (added.nodeName === 'TITLE') { addLink() } }) }) }) obs.observe(document.head, { childList: true, subtree: true }) addLink() const style = document.createElement('style') style.textContent = ` .btn-outline-primary.danime-niconico-added { border-color: #EB5528; color: #EB5528; } .btn-outline-primary.danime-niconico-added:hover { color: #fff; background-color: #EB5528; border-color: #EB5528; } .btn-outline-primary.danime-niconico-added:focus { box-shadow: 0 0 0 0.25rem #EB552880; } ` document.documentElement.appendChild(style) })()