// ==UserScript==
// @name Annict Following Viewings
// @namespace https://github.com/SlashNephy
// @version 0.1.1
// @author SlashNephy
// @description Display following viewings on Annict work page.
// @description:ja Annictの作品ページにフォロー中のユーザーの視聴状況を表示します。
// @homepage https://scrapbox.io/slashnephy
// @homepageURL https://scrapbox.io/slashnephy
// @icon https://www.google.com/s2/favicons?sz=64&domain=annict.com
// @supportURL https://github.com/SlashNephy/.github/issues
// @match https://annict.com/*
// @connect api.annict.com
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @license MIT license
// ==/UserScript==
const executeXhr = async (request) =>
new Promise((resolve, reject) => {
GM_xmlhttpRequest({
...request,
onload: (response) => {
resolve(response)
},
onerror: (error) => {
reject(error)
},
})
})
class GM_Value {
key
defaultValue
constructor(key, defaultValue, initialize = true) {
this.key = key
this.defaultValue = defaultValue
const value = GM_getValue(key, null)
if (initialize && value === null) {
GM_setValue(key, defaultValue)
}
}
get() {
return GM_getValue(this.key, this.defaultValue)
}
set(value) {
GM_setValue(this.key, value)
}
delete() {
GM_deleteValue(this.key)
}
}
const annictTokenRef = new GM_Value('ANNICT_TOKEN')
const fetchFollowingStatuses = async (workId, cursor, token) => {
const response = await executeXhr({
url: 'https://api.annict.com/graphql',
method: 'POST',
data: JSON.stringify({
query: `
query($workId: Int!, $cursor: String) {
viewer {
following(after: $cursor) {
nodes {
name
username
avatarUrl
watched: works(annictIds: [$workId], state: WATCHED) {
nodes {
annictId
}
}
watching: works(annictIds: [$workId], state: WATCHING) {
nodes {
annictId
}
}
stopWatching: works(annictIds: [$workId], state: STOP_WATCHING) {
nodes {
annictId
}
}
onHold: works(annictIds: [$workId], state: ON_HOLD) {
nodes {
annictId
}
}
wannaWatch: works(annictIds: [$workId], state: WANNA_WATCH) {
nodes {
annictId
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
variables: {
workId,
cursor,
},
}),
headers: {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`,
},
})
return JSON.parse(response.responseText)
}
const fetchPaginatedFollowingStatuses = async (workId, token) => {
const results = []
let cursor = null
while (true) {
const response = await fetchFollowingStatuses(workId, cursor, token)
if ('errors' in response) {
return response
}
results.push(response)
if (!response.data.viewer.following.pageInfo.hasNextPage) {
break
}
cursor = response.data.viewer.following.pageInfo.endCursor
}
return results
}
const parseFollowingStatuses = (response) =>
response.data.viewer.following.nodes
.map((u) => {
let label
let iconClasses
let iconColor
if (u.watched.nodes.length > 0) {
label = '見た'
iconClasses = ['far', 'fa-check']
iconColor = '--ann-status-completed-color'
} else if (u.watching.nodes.length > 0) {
label = '見てる'
iconClasses = ['far', 'fa-play']
iconColor = '--ann-status-watching-color'
} else if (u.stopWatching.nodes.length > 0) {
label = '視聴停止'
iconClasses = ['far', 'fa-stop']
iconColor = '--ann-status-dropped-color'
} else if (u.onHold.nodes.length > 0) {
label = '一時中断'
iconClasses = ['far', 'fa-pause']
iconColor = '--ann-status-on-hold-color'
} else if (u.wannaWatch.nodes.length > 0) {
label = '見たい'
iconClasses = ['far', 'fa-circle']
iconColor = '--ann-status-plan-to-watch-color'
} else {
return null
}
return {
name: u.name,
username: u.username,
avatarUrl: u.avatarUrl,
label,
iconClasses,
iconColor,
}
})
.filter((x) => !!x)
const annictWorkPageUrlPattern = /^https:\/\/annict\.com\/works\/(\d+)/
const renderSectionTitle = () => {
const title = document.createElement('div')
title.classList.add('container', 'mt-5')
{
const div = document.createElement('div')
div.classList.add('d-flex', 'justify-content-between')
title.appendChild(div)
}
{
const h2 = document.createElement('h2')
h2.classList.add('fw-bold', 'h3', 'mb-3')
h2.textContent = 'フォロー中のユーザーの視聴状況'
title.appendChild(h2)
}
return title
}
const renderSectionBody = () => {
const body = document.createElement('div')
body.classList.add('container', 'u-container-flat')
{
const card = document.createElement('div')
card.classList.add('card', 'u-card-flat')
body.appendChild(card)
{
const cardBody = document.createElement('div')
cardBody.classList.add('card-body')
cardBody.textContent = '読み込み中...'
card.appendChild(cardBody)
return [body, cardBody]
}
}
}
const renderSectionBodyContent = (statuses) => {
const row = document.createElement('div')
row.classList.add('row', 'g-3')
for (const status of statuses) {
const col = document.createElement('div')
col.classList.add('col-6', 'col-sm-3')
col.style.display = 'flex'
row.appendChild(col)
{
const avatarCol = document.createElement('div')
avatarCol.classList.add('col-auto', 'pe-0')
col.appendChild(avatarCol)
{
const a = document.createElement('a')
a.href = `/@${status.username}`
avatarCol.appendChild(a)
{
const img = document.createElement('img')
img.classList.add('img-thumbnail', 'rounded-circle')
img.style.width = '50px'
img.style.height = '50px'
img.style.marginRight = '1em'
img.src = status.avatarUrl
a.appendChild(img)
}
}
const userCol = document.createElement('div')
userCol.classList.add('col')
col.appendChild(userCol)
{
const div1 = document.createElement('div')
userCol.appendChild(div1)
{
const a = document.createElement('a')
a.classList.add('fw-bold', 'me-1', 'text-body')
a.href = `/@${status.username}`
div1.appendChild(a)
{
const span = document.createElement('span')
span.classList.add('me-1')
span.textContent = status.name
a.appendChild(span)
}
{
const small = document.createElement('small')
small.style.marginRight = '1em'
small.classList.add('text-muted')
small.textContent = `@${status.username}`
a.appendChild(small)
}
}
const div2 = document.createElement('div')
div2.classList.add('small', 'text-body')
userCol.appendChild(div2)
{
const i = document.createElement('i')
i.classList.add(...status.iconClasses)
i.style.color = `var(${status.iconColor})`
div2.appendChild(i)
}
{
const small = document.createElement('small')
small.style.marginLeft = '5px'
small.textContent = status.label
div2.appendChild(small)
}
}
}
}
return row
}
const handle = async () => {
const match = annictWorkPageUrlPattern.exec(window.location.href)
if (!match) {
return
}
const workId = parseInt(match[1], 10)
if (!workId) {
throw new Error('failed to extract Annict work ID')
}
const header = document.querySelector('.c-work-header')
if (header === null) {
throw new Error('failed to find .c-work-header')
}
const title = renderSectionTitle()
header.insertAdjacentElement('afterend', title)
const [body, card] = renderSectionBody()
title.insertAdjacentElement('afterend', body)
const token = annictTokenRef.get()
if (token === undefined) {
const guideAnchor = document.createElement('a')
guideAnchor.href =
'https://scrapbox.io/slashnephy/Annict_%E3%81%AE%E4%BD%9C%E5%93%81%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E3%83%95%E3%82%A9%E3%83%AD%E3%83%BC%E4%B8%AD%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E8%A6%96%E8%81%B4%E7%8A%B6%E6%B3%81%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript'
guideAnchor.textContent = 'ガイド'
guideAnchor.target = '_blank'
card.textContent = ''
card.append(
'この UserScript の動作には Annict の個人用アクセストークンの設定が必要です。こちらの',
guideAnchor,
'を参考に設定を行ってください。'
)
return
}
const responses = await fetchPaginatedFollowingStatuses(workId, token)
if ('errors' in responses) {
const error = responses.errors.map(({ message }) => message).join('\n')
card.textContent = ''
card.append(`Annict GraphQL API がエラーを返しました。\n${error}`)
return
}
const statuses = responses.map((r) => parseFollowingStatuses(r)).flat()
if (statuses.length > 0) {
const content = renderSectionBodyContent(statuses)
card.textContent = ''
card.appendChild(content)
return
}
card.textContent = 'フォロー中のユーザーの視聴状況はありません。'
}
document.addEventListener('turbo:load', () => {
handle().catch(console.error)
})