Annict Following Viewings

Display following viewings on Annict work page.

目前為 2023-03-05 提交的版本,檢視 最新版本

// ==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)
})