// ==UserScript==
// @name Annict dアニメストア ニコニコ支店
// @namespace https://midra.me
// @version 1.0.3
// @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
// ==/UserScript==
(async function() {
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',
get() {
try {
const cache = JSON.parse(GM_getValue(this._cache_key))
return Object.keys(cache).length !== 0 ? cache : undefined
} catch(e) {
console.error(e)
}
},
getLastUpdated() {
return new Date(GM_getValue(this._lastupdated_key)).getTime()
},
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().toString())
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)
},
},
async getList() {
let data = this.cache.get()
if (data === undefined || this.cache.isOld()) {
await this.cache.update()
data = this.cache.get() || data
}
return data
},
async getMatchLink(title) {
const list = await this.getList()
if (list === undefined) return
const annictTitle = this.normalizeTitle(title)
if (annictTitle === '') return
const result = list.reduce((result, item) => {
const itemTitle = this.normalizeTitle(item.title)
if (itemTitle === annictTitle) {
result.item = item
} else if (Math.abs(itemTitle.length - annictTitle.length) <= 15) {
const idxA = itemTitle.indexOf(annictTitle)
const idxB = annictTitle.indexOf(itemTitle)
const idx = idxA !== -1 ? idxA : idxB !== -1 ? idxB : -1
if (idx !== -1) {
result.items.push({ idx, ...item })
}
}
return result
}, { item: null, items: [] })
console.log({ item: result.item, items: [...result.items] })
if (result.item !== null) {
return result.item.url
} else if (result.items.length !== 0) {
result.items.sort((a, b) => a.idx < b.idx ? -1 : b.idx < a.idx ? 1 : 0)
return result.items.pop().url
// return result.items[0]?.url
}
},
normalizeTitle(title = '') {
return title.toLowerCase()
.replace(/[\s-\(\)()「」\[\]]/g, '')
.replace(/[A-Za-z0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
.replace(/./g, s => ({
'〜': '~',
'?': '?',
'!': '!',
'”': '"',
'’': "'",
'´': "'",
'`': '`',
':': ':',
',': ',',
'.': '.',
'・': '・',
'/': '/',
'#': '#',
'$': '$',
'%': '%',
'&': '&',
'ー': '-',
'=': '=',
'@': '@',
}[s] || s))
},
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 url = await ANNICT_EXT.getMatchLink(title.textContent)
if (url) {
if (streamingLinkContainer === null) {
linkContainer.insertAdjacentHTML('beforebegin',
`<ul class="list-inline mt-2"></ul>`
)
streamingLinkContainer = document.querySelector('.c-work-header ul.list-inline.mt-2')
}
streamingLinkContainer.insertAdjacentHTML('beforeend',
`<li class="list-inline-item mt-2"><a class="btn btn-outline-primary btn-sm rounded-pill" href="${url}" target="_blank" rel="noopener">dアニメストア (ニコニコ支店)<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></a></li>`
)
}
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.documentElement, {
childList: true,
subtree: true
})
addLink()
})()